diff --git a/ElementX/Sources/Mocks/SessionVerificationControllerProxyMock.swift b/ElementX/Sources/Mocks/SessionVerificationControllerProxyMock.swift index 7aaddf326..e34bd6599 100644 --- a/ElementX/Sources/Mocks/SessionVerificationControllerProxyMock.swift +++ b/ElementX/Sources/Mocks/SessionVerificationControllerProxyMock.swift @@ -26,9 +26,12 @@ extension SessionVerificationControllerProxyMock { mock.acknowledgeVerificationRequestDetailsReturnValue = .success(()) - mock.requestDeviceVerificationClosure = { [unowned mock] in + mock.requestDeviceVerificationClosure = { [weak mock] in Task.detached { + guard let mock else { return } + try await Task.sleep(for: requestDelay) + mock.actions.send(.acceptedVerificationRequest) if otherDeviceStartsSasVerification { @@ -42,8 +45,10 @@ extension SessionVerificationControllerProxyMock { return .success(()) } - mock.startSasVerificationClosure = { [unowned mock] in + mock.startSasVerificationClosure = { [weak mock] in Task.detached { + guard let mock else { return } + try await Task.sleep(for: requestDelay) mock.actions.send(.startedSasVerification) @@ -56,8 +61,10 @@ extension SessionVerificationControllerProxyMock { return .success(()) } - mock.approveVerificationClosure = { [unowned mock] in + mock.approveVerificationClosure = { [weak mock] in Task.detached { + guard let mock else { return } + try await Task.sleep(for: requestDelay) mock.actions.send(.finished) } @@ -65,8 +72,10 @@ extension SessionVerificationControllerProxyMock { return .success(()) } - mock.declineVerificationClosure = { [unowned mock] in + mock.declineVerificationClosure = { [weak mock] in Task.detached { + guard let mock else { return } + try await Task.sleep(for: requestDelay) mock.actions.send(.cancelled) } @@ -74,8 +83,10 @@ extension SessionVerificationControllerProxyMock { return .success(()) } - mock.cancelVerificationClosure = { [unowned mock] in + mock.cancelVerificationClosure = { [weak mock] in Task.detached { + guard let mock else { return } + try await Task.sleep(for: requestDelay) mock.actions.send(.cancelled) } diff --git a/ElementX/Sources/Screens/RoomNotificationSettingsScreen/RoomNotificationSettingsScreenViewModel.swift b/ElementX/Sources/Screens/RoomNotificationSettingsScreen/RoomNotificationSettingsScreenViewModel.swift index 8d66b49b9..a4f7f1ba1 100644 --- a/ElementX/Sources/Screens/RoomNotificationSettingsScreen/RoomNotificationSettingsScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomNotificationSettingsScreen/RoomNotificationSettingsScreenViewModel.swift @@ -9,7 +9,7 @@ import Combine import SwiftUI -typealias RoomNotificationSettingsScreenViewModelType = StateStoreViewModel +typealias RoomNotificationSettingsScreenViewModelType = StateStoreViewModelV2 class RoomNotificationSettingsScreenViewModel: RoomNotificationSettingsScreenViewModelType, RoomNotificationSettingsScreenViewModelProtocol { private let actionsSubject: PassthroughSubject = .init() @@ -115,7 +115,9 @@ class RoomNotificationSettingsScreenViewModel: RoomNotificationSettingsScreenVie } catch { displayError(.restoreDefaultFailed) } - state.isRestoringDefaultSetting = false + await MainActor.run { + state.isRestoringDefaultSetting = false + } } } @@ -134,7 +136,9 @@ class RoomNotificationSettingsScreenViewModel: RoomNotificationSettingsScreenVie } catch { displayError(.setModeFailed) } - state.pendingCustomMode = nil + await MainActor.run { + state.pendingCustomMode = nil + } } } diff --git a/ElementX/Sources/Screens/RoomNotificationSettingsScreen/View/RoomNotificationSettingsCustomSectionView.swift b/ElementX/Sources/Screens/RoomNotificationSettingsScreen/View/RoomNotificationSettingsCustomSectionView.swift index eba021847..7abf6973f 100644 --- a/ElementX/Sources/Screens/RoomNotificationSettingsScreen/View/RoomNotificationSettingsCustomSectionView.swift +++ b/ElementX/Sources/Screens/RoomNotificationSettingsScreen/View/RoomNotificationSettingsCustomSectionView.swift @@ -10,7 +10,7 @@ import Compound import SwiftUI struct RoomNotificationSettingsCustomSectionView: View { - @ObservedObject var context: RoomNotificationSettingsScreenViewModel.Context + @Bindable var context: RoomNotificationSettingsScreenViewModel.Context var body: some View { Section { diff --git a/ElementX/Sources/Screens/RoomNotificationSettingsScreen/View/RoomNotificationSettingsScreen.swift b/ElementX/Sources/Screens/RoomNotificationSettingsScreen/View/RoomNotificationSettingsScreen.swift index 812e85cbd..7df3461ba 100644 --- a/ElementX/Sources/Screens/RoomNotificationSettingsScreen/View/RoomNotificationSettingsScreen.swift +++ b/ElementX/Sources/Screens/RoomNotificationSettingsScreen/View/RoomNotificationSettingsScreen.swift @@ -10,7 +10,7 @@ import Compound import SwiftUI struct RoomNotificationSettingsScreen: View { - @ObservedObject var context: RoomNotificationSettingsScreenViewModel.Context + @Bindable var context: RoomNotificationSettingsScreenViewModel.Context var body: some View { Form { diff --git a/ElementX/Sources/Screens/RoomNotificationSettingsScreen/View/RoomNotificationSettingsUserDefinedScreen.swift b/ElementX/Sources/Screens/RoomNotificationSettingsScreen/View/RoomNotificationSettingsUserDefinedScreen.swift index d62323f25..dc59544ef 100644 --- a/ElementX/Sources/Screens/RoomNotificationSettingsScreen/View/RoomNotificationSettingsUserDefinedScreen.swift +++ b/ElementX/Sources/Screens/RoomNotificationSettingsScreen/View/RoomNotificationSettingsUserDefinedScreen.swift @@ -10,7 +10,7 @@ import Compound import SwiftUI struct RoomNotificationSettingsUserDefinedScreen: View { - @ObservedObject var context: RoomNotificationSettingsScreenViewModel.Context + @Bindable var context: RoomNotificationSettingsScreenViewModel.Context var body: some View { Form { diff --git a/ElementX/Sources/Services/NotificationSettings/RoomNotificationModeProxy.swift b/ElementX/Sources/Services/NotificationSettings/RoomNotificationModeProxy.swift index 822ca223c..c51911172 100644 --- a/ElementX/Sources/Services/NotificationSettings/RoomNotificationModeProxy.swift +++ b/ElementX/Sources/Services/NotificationSettings/RoomNotificationModeProxy.swift @@ -9,7 +9,7 @@ import Foundation import MatrixRustSDK -enum RoomNotificationModeProxy: String, CaseIterable { +enum RoomNotificationModeProxy: String, CaseIterable, Equatable { case allMessages case mentionsAndKeywordsOnly case mute diff --git a/ElementX/Sources/Services/SessionVerification/SessionVerificationControllerProxyProtocol.swift b/ElementX/Sources/Services/SessionVerification/SessionVerificationControllerProxyProtocol.swift index 06c63c422..2cbd4c7f3 100644 --- a/ElementX/Sources/Services/SessionVerification/SessionVerificationControllerProxyProtocol.swift +++ b/ElementX/Sources/Services/SessionVerification/SessionVerificationControllerProxyProtocol.swift @@ -20,7 +20,7 @@ enum SessionVerificationControllerProxyError: Error { case failedCancellingVerification } -enum SessionVerificationControllerProxyAction { +enum SessionVerificationControllerProxyAction: Equatable { case receivedVerificationRequest(details: SessionVerificationRequestDetails) case acceptedVerificationRequest case startedSasVerification @@ -30,7 +30,7 @@ enum SessionVerificationControllerProxyAction { case failed } -struct SessionVerificationRequestDetails { +struct SessionVerificationRequestDetails: Equatable { let senderProfile: UserProfileProxy let flowID: String let deviceID: String diff --git a/UnitTests/Sources/AttributedStringBuilderTests.swift b/UnitTests/Sources/AttributedStringBuilderTests.swift index 5d583870a..bd1a3c340 100644 --- a/UnitTests/Sources/AttributedStringBuilderTests.swift +++ b/UnitTests/Sources/AttributedStringBuilderTests.swift @@ -7,600 +7,565 @@ // @testable import ElementX -import XCTest +import SwiftUI +import Testing -class AttributedStringBuilderTests: XCTestCase { - private var attributedStringBuilder: AttributedStringBuilder! +@Suite +struct AttributedStringBuilderTests { + private let attributedStringBuilder: AttributedStringBuilder private let maxHeaderPointSize = ceil(UIFont.preferredFont(forTextStyle: .body).pointSize * 1.2) - override func setUp() async throws { + init() async throws { attributedStringBuilder = AttributedStringBuilder(mentionBuilder: MentionBuilder()) } - func testRenderHTMLStringWithHeaders() { - guard let attributedString = attributedStringBuilder.fromHTML(HTMLFixtures.headers.rawValue) else { - XCTFail("Could not build the attributed string") - return - } + @Test + func renderHTMLStringWithHeaders() throws { + let attributedString = try #require(attributedStringBuilder.fromHTML(HTMLFixtures.headers.rawValue), "Could not build the attributed string") - XCTAssertEqual(String(attributedString.characters), "H1 Header\nH2 Header\nH3 Header\nH4 Header\nH5 Header\nH6 Header") + #expect(String(attributedString.characters) == "H1 Header\nH2 Header\nH3 Header\nH4 Header\nH5 Header\nH6 Header") - XCTAssertEqual(attributedString.runs.count, 4) // newlines hold no attributes + #expect(attributedString.runs.count == 4) // newlines hold no attributes let pointSizes = attributedString.runs.compactMap(\.uiKit.font?.pointSize) - XCTAssertEqual(pointSizes, [23, 21, 19, 17]) + #expect(pointSizes == [23, 21, 19, 17]) } - func testRenderHTMLStringWithPreCode() { - guard let attributedString = attributedStringBuilder.fromHTML(HTMLFixtures.code.rawValue) else { - XCTFail("Could not build the attributed string") - return - } + @Test + func renderHTMLStringWithPreCode() throws { + let attributedString = try #require(attributedStringBuilder.fromHTML(HTMLFixtures.code.rawValue), "Could not build the attributed string") - XCTAssertEqual(attributedString.runs.first?.uiKit.font?.fontName, ".AppleSystemUIFontMonospaced-Regular") + #expect(attributedString.runs.first?.uiKit.font?.fontName == ".AppleSystemUIFontMonospaced-Regular") let string = String(attributedString.characters) - guard let regex = try? NSRegularExpression(pattern: "\\R", options: []) else { - XCTFail("Could not build the regex for the test.") - return - } + let regex = try #require(try? NSRegularExpression(pattern: "\\R", options: []), "Could not build the regex for the test.") - XCTAssertEqual(regex.numberOfMatches(in: string, options: [], range: .init(location: 0, length: string.count)), 23) + #expect(regex.numberOfMatches(in: string, options: [], range: .init(location: 0, length: string.count)) == 23) } - func testRenderHTMLStringWithLink() { - guard let attributedString = attributedStringBuilder.fromHTML(HTMLFixtures.links.rawValue) else { - XCTFail("Could not build the attributed string") - return - } + @Test + func renderHTMLStringWithLink() throws { + let attributedString = try #require(attributedStringBuilder.fromHTML(HTMLFixtures.links.rawValue), "Could not build the attributed string") - XCTAssertEqual(String(attributedString.characters), "Links too:\nMatrix rules! 🤘, beta.org, www.gamma.org, http://delta.org") + #expect(String(attributedString.characters) == "Links too:\nMatrix rules! 🤘, beta.org, www.gamma.org, http://delta.org") let link = attributedString.runs.first { $0.link != nil }?.link - XCTAssertEqual(link?.host, "www.alpha.org") + #expect(link?.host == "www.alpha.org") } - func testRenderPlainStringWithLink() { + @Test + func renderPlainStringWithLink() throws { let plainString = "This text contains a https://www.matrix.org link." - guard let attributedString = attributedStringBuilder.fromPlain(plainString) else { - XCTFail("Could not build the attributed string") - return - } + let attributedString = try #require(attributedStringBuilder.fromPlain(plainString), "Could not build the attributed string") - XCTAssertEqual(String(attributedString.characters), plainString) + #expect(String(attributedString.characters) == plainString) - XCTAssertEqual(attributedString.runs.count, 3) + #expect(attributedString.runs.count == 3) let link = attributedString.runs.first { $0.link != nil }?.link - XCTAssertEqual(link?.host, "www.matrix.org") + #expect(link?.host == "www.matrix.org") } - func testPunctuationAtTheEndOfPlainStringLinks() { + @Test + func punctuationAtTheEndOfPlainStringLinks() throws { let plainString = "Most punctuation marks are removed https://www.matrix.org:;., but closing brackets are kept https://example.com/(test)" - guard let attributedString = attributedStringBuilder.fromPlain(plainString) else { - XCTFail("Could not build the attributed string") - return - } + let attributedString = try #require(attributedStringBuilder.fromPlain(plainString), "Could not build the attributed string") - XCTAssertEqual(String(attributedString.characters), plainString) + #expect(String(attributedString.characters) == plainString) - XCTAssertEqual(attributedString.runs.count, 4) + #expect(attributedString.runs.count == 4) let firstLink = attributedString.runs.first { $0.link != nil }?.link - XCTAssertEqual(firstLink, "https://www.matrix.org") + #expect(firstLink == "https://www.matrix.org") let secondLink = attributedString.runs.last { $0.link != nil }?.link - XCTAssertEqual(secondLink, "https://example.com/(test)") + #expect(secondLink == "https://example.com/(test)") } - func testLinkDefaultScheme() { + @Test + func linkDefaultScheme() throws { let plainString = "This text contains a matrix.org link." - guard let attributedString = attributedStringBuilder.fromPlain(plainString) else { - XCTFail("Could not build the attributed string") - return - } + let attributedString = try #require(attributedStringBuilder.fromPlain(plainString), "Could not build the attributed string") - XCTAssertEqual(String(attributedString.characters), plainString) + #expect(String(attributedString.characters) == plainString) - XCTAssertEqual(attributedString.runs.count, 3) + #expect(attributedString.runs.count == 3) let link = attributedString.runs.first { $0.link != nil }?.link - XCTAssertEqual(link, "https://matrix.org") + #expect(link == "https://matrix.org") } - func testMailToLinks() { + @Test + func mailToLinks() throws { let plainString = "Linking to email addresses like stefan@matrix.org should work as well" - guard let attributedString = attributedStringBuilder.fromPlain(plainString) else { - XCTFail("Could not build the attributed string") - return - } + let attributedString = try #require(attributedStringBuilder.fromPlain(plainString), "Could not build the attributed string") let link = attributedString.runs.first { $0.link != nil }?.link - XCTAssertEqual(link, "mailto:stefan@matrix.org") + #expect(link == "mailto:stefan@matrix.org") } - func testRenderHTMLStringWithLinkInHeader() { + @Test + func renderHTMLStringWithLinkInHeader() throws { let h1HTMLString = "

Matrix.org

" let h2HTMLString = "

Matrix.org

" let h3HTMLString = "

Matrix.org

" - guard let h1AttributedString = attributedStringBuilder.fromHTML(h1HTMLString), - let h2AttributedString = attributedStringBuilder.fromHTML(h2HTMLString), - let h3AttributedString = attributedStringBuilder.fromHTML(h3HTMLString) else { - XCTFail("Could not build the attributed string") - return - } + let h1AttributedString = try #require(attributedStringBuilder.fromHTML(h1HTMLString), "Could not build the attributed string") + let h2AttributedString = try #require(attributedStringBuilder.fromHTML(h2HTMLString), "Could not build the attributed string") + let h3AttributedString = try #require(attributedStringBuilder.fromHTML(h3HTMLString), "Could not build the attributed string") - guard let h1Font = h1AttributedString.runs.first?.uiKit.font, - let h2Font = h2AttributedString.runs.first?.uiKit.font, - let h3Font = h3AttributedString.runs.first?.uiKit.font else { - XCTFail("Could not extract a font from the strings.") - return - } + let h1Font = try #require(h1AttributedString.runs.first?.uiKit.font, "Could not extract a font from the strings.") + let h2Font = try #require(h2AttributedString.runs.first?.uiKit.font, "Could not extract a font from the strings.") + let h3Font = try #require(h3AttributedString.runs.first?.uiKit.font, "Could not extract a font from the strings.") - XCTAssertEqual(String(h1AttributedString.characters), "Matrix.org") - XCTAssertEqual(String(h2AttributedString.characters), "Matrix.org") - XCTAssertEqual(String(h3AttributedString.characters), "Matrix.org") + #expect(String(h1AttributedString.characters) == "Matrix.org") + #expect(String(h2AttributedString.characters) == "Matrix.org") + #expect(String(h3AttributedString.characters) == "Matrix.org") - XCTAssertEqual(h1AttributedString.runs.count, 1) - XCTAssertEqual(h2AttributedString.runs.count, 1) - XCTAssertEqual(h3AttributedString.runs.count, 1) + #expect(h1AttributedString.runs.count == 1) + #expect(h2AttributedString.runs.count == 1) + #expect(h3AttributedString.runs.count == 1) - XCTAssertEqual(h1Font, h2Font) - XCTAssertEqual(h2Font, h3Font) + #expect(h1Font == h2Font) + #expect(h2Font == h3Font) - XCTAssert(h1Font.pointSize > UIFont.preferredFont(forTextStyle: .body).pointSize) - XCTAssert(h1Font.pointSize <= 23) + #expect(h1Font.pointSize > UIFont.preferredFont(forTextStyle: .body).pointSize) + #expect(h1Font.pointSize <= 23) - XCTAssertEqual(h1AttributedString.runs.first?.link?.host, "matrix.org") - XCTAssertEqual(h2AttributedString.runs.first?.link?.host, "matrix.org") - XCTAssertEqual(h3AttributedString.runs.first?.link?.host, "matrix.org") + #expect(h1AttributedString.runs.first?.link?.host == "matrix.org") + #expect(h2AttributedString.runs.first?.link?.host == "matrix.org") + #expect(h3AttributedString.runs.first?.link?.host == "matrix.org") } - func testRenderHTMLStringWithIFrame() { + @Test + func renderHTMLStringWithIFrame() throws { let htmlString = "" - guard let attributedString = attributedStringBuilder.fromHTML(htmlString) else { - XCTFail("Could not build the attributed string") - return - } + let attributedString = try #require(attributedStringBuilder.fromHTML(htmlString), "Could not build the attributed string") - XCTAssertNil(attributedString.uiKit.attachment, "iFrame attachments should be removed as they're not included in the allowedHTMLTags array.") + #expect(attributedString.uiKit.attachment == nil, + "iFrame attachments should be removed as they're not included in the allowedHTMLTags array.") } - func testLinkWithFragment() { + @Test + func linkWithFragment() throws { var string = "https://example.com/#/" - checkLinkIn(attributedString: attributedStringBuilder.fromHTML(string), expectedLink: "https://example.com", expectedRuns: 1) - checkLinkIn(attributedString: attributedStringBuilder.fromPlain(string), expectedLink: "https://example.com", expectedRuns: 1) + try checkLinkIn(attributedString: attributedStringBuilder.fromHTML(string), expectedLink: "https://example.com", expectedRuns: 1) + try checkLinkIn(attributedString: attributedStringBuilder.fromPlain(string), expectedLink: "https://example.com", expectedRuns: 1) string = "https://example.com/#/some_fragment/" - checkLinkIn(attributedString: attributedStringBuilder.fromHTML(string), expectedLink: "https://example.com/#/some_fragment", expectedRuns: 1) - checkLinkIn(attributedString: attributedStringBuilder.fromPlain(string), expectedLink: "https://example.com/#/some_fragment", expectedRuns: 1) + try checkLinkIn(attributedString: attributedStringBuilder.fromHTML(string), expectedLink: "https://example.com/#/some_fragment", expectedRuns: 1) + try checkLinkIn(attributedString: attributedStringBuilder.fromPlain(string), expectedLink: "https://example.com/#/some_fragment", expectedRuns: 1) } - func testPermalink() { + @Test + func permalink() throws { let string = "https://matrix.to/#/!hello:matrix.org/$world?via=matrix.org" - checkLinkIn(attributedString: attributedStringBuilder.fromHTML(string), expectedLink: string, expectedRuns: 1) - checkLinkIn(attributedString: attributedStringBuilder.fromPlain(string), expectedLink: string, expectedRuns: 1) + try checkLinkIn(attributedString: attributedStringBuilder.fromHTML(string), expectedLink: string, expectedRuns: 1) + try checkLinkIn(attributedString: attributedStringBuilder.fromPlain(string), expectedLink: string, expectedRuns: 1) } - func testMatrixURI() { + @Test + func matrixURI() throws { let string = "matrix:roomid/hello:matrix.org/e/world?via=matrix.org" - checkLinkIn(attributedString: attributedStringBuilder.fromHTML(string), expectedLink: string, expectedRuns: 1) - checkLinkIn(attributedString: attributedStringBuilder.fromPlain(string), expectedLink: string, expectedRuns: 1) + try checkLinkIn(attributedString: attributedStringBuilder.fromHTML(string), expectedLink: string, expectedRuns: 1) + try checkLinkIn(attributedString: attributedStringBuilder.fromPlain(string), expectedLink: string, expectedRuns: 1) } - func testUserIDLink() { + @Test + func userIDLink() throws { let userID = "@user:matrix.org" let string = "The user is \(userID)." let expectedLink = "https://matrix.to/#/\(userID)" - checkLinkIn(attributedString: attributedStringBuilder.fromHTML(string), expectedLink: expectedLink, expectedRuns: 3) - checkLinkIn(attributedString: attributedStringBuilder.fromPlain(string), expectedLink: expectedLink, expectedRuns: 3) + try checkLinkIn(attributedString: attributedStringBuilder.fromHTML(string), expectedLink: expectedLink, expectedRuns: 3) + try checkLinkIn(attributedString: attributedStringBuilder.fromPlain(string), expectedLink: expectedLink, expectedRuns: 3) } - func testRoomAliasLink() { + @Test + func roomAliasLink() throws { let roomAlias = "#room:matrix.org" let string = "The room is \(roomAlias)." - guard let expectedLink = URL(string: "https://matrix.to/#/\(roomAlias)") else { - XCTFail("The expected link should be valid.") - return - } - checkLinkIn(attributedString: attributedStringBuilder.fromHTML(string), expectedLink: expectedLink.absoluteString, expectedRuns: 3) - checkLinkIn(attributedString: attributedStringBuilder.fromPlain(string), expectedLink: expectedLink.absoluteString, expectedRuns: 3) + let expectedLink = try #require(URL(string: "https://matrix.to/#/\(roomAlias)"), "The expected link should be valid.") + try checkLinkIn(attributedString: attributedStringBuilder.fromHTML(string), expectedLink: expectedLink.absoluteString, expectedRuns: 3) + try checkLinkIn(attributedString: attributedStringBuilder.fromPlain(string), expectedLink: expectedLink.absoluteString, expectedRuns: 3) } - func testDefaultFont() { + @Test + func defaultFont() throws { let htmlString = "Test string " - guard let attributedString = attributedStringBuilder.fromHTML(htmlString) else { - XCTFail("Could not build the attributed string") - return - } + let attributedString = try #require(attributedStringBuilder.fromHTML(htmlString), "Could not build the attributed string") - XCTAssertEqual(attributedString.runs.count, 3) + #expect(attributedString.runs.count == 3) } - func testDefaultForegroundColor() { + @Test + func defaultForegroundColor() throws { let htmlString = "Test string link link" - guard let attributedString = attributedStringBuilder.fromHTML(htmlString) else { - XCTFail("Could not build the attributed string") - return - } + let attributedString = try #require(attributedStringBuilder.fromHTML(htmlString), "Could not build the attributed string") - XCTAssertEqual(attributedString.runs.count, 7) + #expect(attributedString.runs.count == 7) for run in attributedString.runs { - XCTAssertNil(run.uiKit.foregroundColor) + #expect(run.uiKit.foregroundColor == nil) } } - func testCustomForegroundColor() { + @Test + func customForegroundColor() throws { // swiftlint:disable:next line_length let htmlString = "Rain www.matrix.org bow" - guard let attributedString = attributedStringBuilder.fromHTML(htmlString) else { - XCTFail("Could not build the attributed string") - return - } + let attributedString = try #require(attributedStringBuilder.fromHTML(htmlString), "Could not build the attributed string") - XCTAssertEqual(attributedString.runs.count, 3) + #expect(attributedString.runs.count == 3) var foundLink = false // Foreground colors should be completely stripped from the attributed string // letting UI components chose the defaults (e.g. tintColor) for run in attributedString.runs { if run.link != nil { - XCTAssertEqual(run.link?.host, "www.matrix.org") - XCTAssertNil(run.uiKit.foregroundColor) + #expect(run.link?.host == "www.matrix.org") + #expect(run.uiKit.foregroundColor == nil) foundLink = true } else { - XCTAssertNil(run.uiKit.foregroundColor) + #expect(run.uiKit.foregroundColor == nil) } } - XCTAssertTrue(foundLink) + #expect(foundLink) } - func testSingleBlockquote() { + @Test + func singleBlockquote() throws { let htmlString = "
Blockquote

Another paragraph

" - guard let attributedString = attributedStringBuilder.fromHTML(htmlString) else { - XCTFail("Could not build the attributed string") - return - } + let attributedString = try #require(attributedStringBuilder.fromHTML(htmlString), "Could not build the attributed string") - XCTAssertEqual(attributedString.runs.count, 2) + #expect(attributedString.runs.count == 2) - XCTAssertEqual(attributedString.formattedComponents.count, 2) + #expect(attributedString.formattedComponents.count == 2) for run in attributedString.runs where run.elementX.blockquote ?? false { return } - XCTFail("Couldn't find blockquote") + Issue.record("Couldn't find blockquote") - XCTAssertEqual(String(attributedString.characters), "Blockquote\nAnother paragraph") + #expect(String(attributedString.characters) == "Blockquote\nAnother paragraph") } // swiftlint:disable line_length - func testBlockquoteWithinText() { + @Test + func blockquoteWithinText() throws { let htmlString = """ The text before the blockquote
For 50 years, WWF has been protecting the future of nature. The world's leading conservation organization, WWF works in 100 countries and is supported by 1.2 million members in the United States and close to 5 million globally.
The text after the blockquote """ - guard let attributedString = attributedStringBuilder.fromHTML(htmlString) else { - XCTFail("Could not build the attributed string") - return - } + let attributedString = try #require(attributedStringBuilder.fromHTML(htmlString), "Could not build the attributed string") - XCTAssertEqual(attributedString.runs.count, 3) + #expect(attributedString.runs.count == 3) - XCTAssertEqual(attributedString.formattedComponents.count, 3) + #expect(attributedString.formattedComponents.count == 3) for run in attributedString.runs where run.elementX.blockquote ?? false { return } - XCTFail("Couldn't find blockquote") + Issue.record("Couldn't find blockquote") } // swiftlint:enable line_length - func testBlockquoteWithLink() { + @Test + func blockquoteWithLink() throws { let htmlString = "
Blockquote with a link in it
" - guard let attributedString = attributedStringBuilder.fromHTML(htmlString) else { - XCTFail("Could not build the attributed string") - return - } + let attributedString = try #require(attributedStringBuilder.fromHTML(htmlString), "Could not build the attributed string") - XCTAssertEqual(attributedString.runs.count, 3) + #expect(attributedString.runs.count == 3) let coalescedComponents = attributedString.formattedComponents - XCTAssertEqual(coalescedComponents.count, 1) + #expect(coalescedComponents.count == 1) - XCTAssertEqual(coalescedComponents.first?.attributedString.runs.count, 3, "Link not present in the component") + #expect(coalescedComponents.first?.attributedString.runs.count == 3, "Link not present in the component") var foundBlockquoteAndLink = false for run in attributedString.runs where run.elementX.blockquote ?? false && run.link != nil { foundBlockquoteAndLink = true } - XCTAssertNotNil(foundBlockquoteAndLink, "Couldn't find blockquote or link") + #expect(foundBlockquoteAndLink != nil, "Couldn't find blockquote or link") } - func testReplyBlockquote() { + @Test + func replyBlockquote() throws { let htmlString = "
In reply to @user:matrix.org
The future is swift run tools 😎
" - guard let attributedString = attributedStringBuilder.fromHTML(htmlString) else { - XCTFail("Could not build the attributed string") - return - } + let attributedString = try #require(attributedStringBuilder.fromHTML(htmlString), "Could not build the attributed string") let coalescedComponents = attributedString.formattedComponents - XCTAssertEqual(coalescedComponents.count, 1) + #expect(coalescedComponents.count == 1) - guard let component = coalescedComponents.first else { - XCTFail("Could not get the first component") - return - } + let component = try #require(coalescedComponents.first, "Could not get the first component") - XCTAssertTrue(component.type == .blockquote, "The reply quote should be a blockquote.") + #expect(component.type == .blockquote, "The reply quote should be a blockquote.") } - func testMultipleGroupedBlockquotes() { - guard let attributedString = attributedStringBuilder.fromHTML(HTMLFixtures.groupedBlockQuotes.rawValue) else { - XCTFail("Could not build the attributed string") - return - } + @Test + func multipleGroupedBlockquotes() throws { + let attributedString = try #require(attributedStringBuilder.fromHTML(HTMLFixtures.groupedBlockQuotes.rawValue), "Could not build the attributed string") - XCTAssertEqual(attributedString.runs.count, 11) - XCTAssertEqual(attributedString.formattedComponents.count, 5) + #expect(attributedString.runs.count == 11) + #expect(attributedString.formattedComponents.count == 5) var numberOfBlockquotes = 0 for run in attributedString.runs where run.elementX.blockquote ?? false && run.link != nil { numberOfBlockquotes += 1 } - XCTAssertEqual(numberOfBlockquotes, 3, "Couldn't find all the blockquotes") + #expect(numberOfBlockquotes == 3, "Couldn't find all the blockquotes") } - func testMultipleSeparatedBlockquotes() { - guard let attributedString = attributedStringBuilder.fromHTML(HTMLFixtures.separatedBlockQuotes.rawValue) else { - XCTFail("Could not build the attributed string") - return - } + @Test + func multipleSeparatedBlockquotes() throws { + let attributedString = try #require(attributedStringBuilder.fromHTML(HTMLFixtures.separatedBlockQuotes.rawValue), "Could not build the attributed string") let coalescedComponents = attributedString.formattedComponents - XCTAssertEqual(attributedString.runs.count, 5) - XCTAssertEqual(coalescedComponents.count, 5) + #expect(attributedString.runs.count == 5) + #expect(coalescedComponents.count == 5) var numberOfBlockquotes = 0 for run in attributedString.runs where run.elementX.blockquote ?? false { numberOfBlockquotes += 1 } - XCTAssertEqual(numberOfBlockquotes, 2, "Couldn't find all the blockquotes") + #expect(numberOfBlockquotes == 2, "Couldn't find all the blockquotes") } - func testUserPermalinkMentionAtachment() { + @Test + func userPermalinkMentionAtachment() { let string = "https://matrix.to/#/@test:matrix.org" let attributedStringFromHTML = attributedStringBuilder.fromHTML(string) - XCTAssertNotNil(attributedStringFromHTML?.attachment) - XCTAssertEqual(attributedStringFromHTML?.userID, "@test:matrix.org") - XCTAssertEqual(attributedStringFromHTML?.link?.absoluteString, string) + #expect(attributedStringFromHTML?.attachment != nil) + #expect(attributedStringFromHTML?.userID == "@test:matrix.org") + #expect(attributedStringFromHTML?.link?.absoluteString == string) let attributedStringFromPlain = attributedStringBuilder.fromPlain(string) - XCTAssertNotNil(attributedStringFromPlain?.attachment) - XCTAssertEqual(attributedStringFromPlain?.userID, "@test:matrix.org") - XCTAssertEqual(attributedStringFromPlain?.link?.absoluteString, string) + #expect(attributedStringFromPlain?.attachment != nil) + #expect(attributedStringFromPlain?.userID == "@test:matrix.org") + #expect(attributedStringFromPlain?.link?.absoluteString == string) } - func testUserIDMentionAtachment() { + @Test + func userIDMentionAtachment() { let string = "@test:matrix.org" let attributedStringFromHTML = attributedStringBuilder.fromHTML(string) - XCTAssertNotNil(attributedStringFromHTML?.attachment) - XCTAssertEqual(attributedStringFromHTML?.userID, "@test:matrix.org") - XCTAssertEqual(attributedStringFromHTML?.link?.absoluteString, "https://matrix.to/#/@test:matrix.org") + #expect(attributedStringFromHTML?.attachment != nil) + #expect(attributedStringFromHTML?.userID == "@test:matrix.org") + #expect(attributedStringFromHTML?.link?.absoluteString == "https://matrix.to/#/@test:matrix.org") let attributedStringFromPlain = attributedStringBuilder.fromPlain(string) - XCTAssertNotNil(attributedStringFromPlain?.attachment) - XCTAssertEqual(attributedStringFromPlain?.userID, "@test:matrix.org") - XCTAssertEqual(attributedStringFromPlain?.link?.absoluteString, "https://matrix.to/#/@test:matrix.org") + #expect(attributedStringFromPlain?.attachment != nil) + #expect(attributedStringFromPlain?.userID == "@test:matrix.org") + #expect(attributedStringFromPlain?.link?.absoluteString == "https://matrix.to/#/@test:matrix.org") } - func testRoomIDPermalinkMentionAttachment() { + @Test + func roomIDPermalinkMentionAttachment() { let string = "https://matrix.to/#/!test:matrix.org" let attributedStringFromHTML = attributedStringBuilder.fromHTML(string) - XCTAssertNotNil(attributedStringFromHTML?.attachment) - XCTAssertEqual(attributedStringFromHTML?.roomID, "!test:matrix.org") - XCTAssertEqual(attributedStringFromHTML?.link?.absoluteString, string) + #expect(attributedStringFromHTML?.attachment != nil) + #expect(attributedStringFromHTML?.roomID == "!test:matrix.org") + #expect(attributedStringFromHTML?.link?.absoluteString == string) let attributedStringFromPlain = attributedStringBuilder.fromPlain(string) - XCTAssertNotNil(attributedStringFromPlain?.attachment) - XCTAssertEqual(attributedStringFromHTML?.roomID, "!test:matrix.org") - XCTAssertEqual(attributedStringFromPlain?.link?.absoluteString, string) + #expect(attributedStringFromPlain?.attachment != nil) + #expect(attributedStringFromHTML?.roomID == "!test:matrix.org") + #expect(attributedStringFromPlain?.link?.absoluteString == string) } - func testRoomAliasPermalinkMentionAttachment() { + @Test + func roomAliasPermalinkMentionAttachment() { let string = "https://matrix.to/#/#test:matrix.org" let attributedStringFromHTML = attributedStringBuilder.fromHTML(string) - XCTAssertNotNil(attributedStringFromHTML?.attachment) - XCTAssertEqual(attributedStringFromHTML?.roomAlias, "#test:matrix.org") - XCTAssertEqual(attributedStringFromHTML?.link?.absoluteString, "https://matrix.to/#/%23test:matrix.org") + #expect(attributedStringFromHTML?.attachment != nil) + #expect(attributedStringFromHTML?.roomAlias == "#test:matrix.org") + #expect(attributedStringFromHTML?.link?.absoluteString == "https://matrix.to/#/%23test:matrix.org") let attributedStringFromPlain = attributedStringBuilder.fromPlain(string) - XCTAssertNotNil(attributedStringFromPlain?.attachment) - XCTAssertEqual(attributedStringFromHTML?.roomAlias, "#test:matrix.org") - XCTAssertEqual(attributedStringFromPlain?.link?.absoluteString, "https://matrix.to/#/%23test:matrix.org") + #expect(attributedStringFromPlain?.attachment != nil) + #expect(attributedStringFromHTML?.roomAlias == "#test:matrix.org") + #expect(attributedStringFromPlain?.link?.absoluteString == "https://matrix.to/#/%23test:matrix.org") } - func testRoomAliasMentionAttachment() { + @Test + func roomAliasMentionAttachment() { let string = "#test:matrix.org" let attributedStringFromHTML = attributedStringBuilder.fromHTML(string) - XCTAssertNotNil(attributedStringFromHTML?.attachment) - XCTAssertEqual(attributedStringFromHTML?.roomAlias, "#test:matrix.org") - XCTAssertEqual(attributedStringFromHTML?.link?.absoluteString, "https://matrix.to/#/%23test:matrix.org") + #expect(attributedStringFromHTML?.attachment != nil) + #expect(attributedStringFromHTML?.roomAlias == "#test:matrix.org") + #expect(attributedStringFromHTML?.link?.absoluteString == "https://matrix.to/#/%23test:matrix.org") let attributedStringFromPlain = attributedStringBuilder.fromPlain(string) - XCTAssertNotNil(attributedStringFromPlain?.attachment) - XCTAssertEqual(attributedStringFromHTML?.roomAlias, "#test:matrix.org") - XCTAssertEqual(attributedStringFromPlain?.link?.absoluteString, "https://matrix.to/#/%23test:matrix.org") + #expect(attributedStringFromPlain?.attachment != nil) + #expect(attributedStringFromHTML?.roomAlias == "#test:matrix.org") + #expect(attributedStringFromPlain?.link?.absoluteString == "https://matrix.to/#/%23test:matrix.org") } - func testEventRoomIDPermalinkMentionAttachment() { + @Test + func eventRoomIDPermalinkMentionAttachment() { let string = "https://matrix.to/#/!test:matrix.org/$test" let attributedStringFromHTML = attributedStringBuilder.fromHTML(string) - XCTAssertNotNil(attributedStringFromHTML?.attachment) - XCTAssertEqual(attributedStringFromHTML?.eventOnRoomID, .some(.init(roomID: "!test:matrix.org", eventID: "$test"))) - XCTAssertEqual(attributedStringFromHTML?.link?.absoluteString, string) + #expect(attributedStringFromHTML?.attachment != nil) + #expect(attributedStringFromHTML?.eventOnRoomID == .some(.init(roomID: "!test:matrix.org", eventID: "$test"))) + #expect(attributedStringFromHTML?.link?.absoluteString == string) let attributedStringFromPlain = attributedStringBuilder.fromPlain(string) - XCTAssertNotNil(attributedStringFromPlain?.attachment) - XCTAssertEqual(attributedStringFromPlain?.eventOnRoomID, .some(.init(roomID: "!test:matrix.org", eventID: "$test"))) - XCTAssertEqual(attributedStringFromPlain?.link?.absoluteString, string) + #expect(attributedStringFromPlain?.attachment != nil) + #expect(attributedStringFromPlain?.eventOnRoomID == .some(.init(roomID: "!test:matrix.org", eventID: "$test"))) + #expect(attributedStringFromPlain?.link?.absoluteString == string) } - func testEventRoomAliasPermalinkMentionAttachment() { + @Test + func eventRoomAliasPermalinkMentionAttachment() { let string = "https://matrix.to/#/#test:matrix.org/$test" let attributedStringFromHTML = attributedStringBuilder.fromHTML(string) - XCTAssertNotNil(attributedStringFromHTML?.attachment) - XCTAssertEqual(attributedStringFromHTML?.eventOnRoomAlias, .some(.init(alias: "#test:matrix.org", eventID: "$test"))) - XCTAssertEqual(attributedStringFromHTML?.link?.absoluteString, "https://matrix.to/#/%23test:matrix.org/$test") + #expect(attributedStringFromHTML?.attachment != nil) + #expect(attributedStringFromHTML?.eventOnRoomAlias == .some(.init(alias: "#test:matrix.org", eventID: "$test"))) + #expect(attributedStringFromHTML?.link?.absoluteString == "https://matrix.to/#/%23test:matrix.org/$test") let attributedStringFromPlain = attributedStringBuilder.fromPlain(string) - XCTAssertNotNil(attributedStringFromPlain?.attachment) - XCTAssertEqual(attributedStringFromPlain?.eventOnRoomAlias, .some(.init(alias: "#test:matrix.org", eventID: "$test"))) - XCTAssertEqual(attributedStringFromPlain?.link?.absoluteString, "https://matrix.to/#/%23test:matrix.org/$test") + #expect(attributedStringFromPlain?.attachment != nil) + #expect(attributedStringFromPlain?.eventOnRoomAlias == .some(.init(alias: "#test:matrix.org", eventID: "$test"))) + #expect(attributedStringFromPlain?.link?.absoluteString == "https://matrix.to/#/%23test:matrix.org/$test") } - func testUserMentionAtachmentInBlockQuotes() { + @Test + func userMentionAtachmentInBlockQuotes() throws { let link = "https://matrix.to/#/@test:matrix.org" let string = "
hello \(link) how are you?
" - guard let attributedStringFromHTML = attributedStringBuilder.fromHTML(string) else { - XCTFail("Attributed string is nil") - return - } + let attributedStringFromHTML = try #require(attributedStringBuilder.fromHTML(string), "Attributed string is nil") for run in attributedStringFromHTML.runs { - XCTAssertNotNil(run.blockquote) + #expect(run.blockquote != nil) } - checkAttachment(attributedString: attributedStringFromHTML, expectedRuns: 3) - checkLinkIn(attributedString: attributedStringFromHTML, expectedLink: link, expectedRuns: 3) + try checkAttachment(attributedString: attributedStringFromHTML, expectedRuns: 3) + try checkLinkIn(attributedString: attributedStringFromHTML, expectedLink: link, expectedRuns: 3) } - func testAllUsersMentionAtachmentInBlockQuotes() { + @Test + func allUsersMentionAtachmentInBlockQuotes() throws { let string = "
hello @room how are you?
" - guard let attributedStringFromHTML = attributedStringBuilder.fromHTML(string) else { - XCTFail("Attributed string is nil") - return - } + let attributedStringFromHTML = try #require(attributedStringBuilder.fromHTML(string), "Attributed string is nil") for run in attributedStringFromHTML.runs { - XCTAssertNotNil(run.blockquote) + #expect(run.blockquote != nil) } - checkAttachment(attributedString: attributedStringFromHTML, expectedRuns: 3) + try checkAttachment(attributedString: attributedStringFromHTML, expectedRuns: 3) } - func testAllUsersMentionAttachment() { + @Test + func allUsersMentionAttachment() throws { let string = "@room" let attributedStringFromHTML = attributedStringBuilder.fromHTML(string) - checkAttachment(attributedString: attributedStringFromHTML, expectedRuns: 1) + try checkAttachment(attributedString: attributedStringFromHTML, expectedRuns: 1) let attributedStringFromPlain = attributedStringBuilder.fromPlain(string) - checkAttachment(attributedString: attributedStringFromPlain, expectedRuns: 1) + try checkAttachment(attributedString: attributedStringFromPlain, expectedRuns: 1) let string2 = "Hello @room" let attributedStringFromHTML2 = attributedStringBuilder.fromHTML(string2) - checkAttachment(attributedString: attributedStringFromHTML2, expectedRuns: 2) + try checkAttachment(attributedString: attributedStringFromHTML2, expectedRuns: 2) let attributedStringFromPlain2 = attributedStringBuilder.fromPlain(string2) - checkAttachment(attributedString: attributedStringFromPlain2, expectedRuns: 2) + try checkAttachment(attributedString: attributedStringFromPlain2, expectedRuns: 2) let string3 = "Hello @room how are you doing?" let attributedStringFromHTML3 = attributedStringBuilder.fromHTML(string3) - checkAttachment(attributedString: attributedStringFromHTML3, expectedRuns: 3) + try checkAttachment(attributedString: attributedStringFromHTML3, expectedRuns: 3) let attributedStringFromPlain3 = attributedStringBuilder.fromPlain(string3) - checkAttachment(attributedString: attributedStringFromPlain3, expectedRuns: 3) + try checkAttachment(attributedString: attributedStringFromPlain3, expectedRuns: 3) } - func testLinksHavePriorityOverAllUserMention() { + @Test + func linksHavePriorityOverAllUserMention() throws { let string = "https://test@room.org" let attributedStringFromHTML = attributedStringBuilder.fromHTML(string) - checkLinkIn(attributedString: attributedStringFromHTML, expectedLink: string, expectedRuns: 1) + try checkLinkIn(attributedString: attributedStringFromHTML, expectedLink: string, expectedRuns: 1) let attributedStringFromPlain = attributedStringBuilder.fromPlain(string) - checkLinkIn(attributedString: attributedStringFromPlain, expectedLink: string, expectedRuns: 1) + try checkLinkIn(attributedString: attributedStringFromPlain, expectedLink: string, expectedRuns: 1) let string2 = "https://matrix.to/#/@roomusername:matrix.org" let attributedStringFromHTML2 = attributedStringBuilder.fromHTML(string2) - checkLinkIn(attributedString: attributedStringFromHTML2, expectedLink: string2, expectedRuns: 1) - checkAttachment(attributedString: attributedStringFromHTML2, expectedRuns: 1) + try checkLinkIn(attributedString: attributedStringFromHTML2, expectedLink: string2, expectedRuns: 1) + try checkAttachment(attributedString: attributedStringFromHTML2, expectedRuns: 1) let attributedStringFromPlain2 = attributedStringBuilder.fromPlain(string2) - checkLinkIn(attributedString: attributedStringFromPlain2, expectedLink: string2, expectedRuns: 1) - checkAttachment(attributedString: attributedStringFromPlain2, expectedRuns: 1) + try checkLinkIn(attributedString: attributedStringFromPlain2, expectedLink: string2, expectedRuns: 1) + try checkAttachment(attributedString: attributedStringFromPlain2, expectedRuns: 1) } - func testURLsAreIgnoredInCode() { + @Test + func uRLsAreIgnoredInCode() { var htmlString = "
test https://matrix.org test
" var attributedStringFromHTML = attributedStringBuilder.fromHTML(htmlString) - XCTAssert(attributedStringFromHTML?.runs.count == 1) - XCTAssertNil(attributedStringFromHTML?.link) + #expect(attributedStringFromHTML?.runs.count == 1) + #expect(attributedStringFromHTML?.link == nil) htmlString = "
matrix.org
" attributedStringFromHTML = attributedStringBuilder.fromHTML(htmlString) - XCTAssert(attributedStringFromHTML?.runs.count == 1) - XCTAssertNil(attributedStringFromHTML?.link) + #expect(attributedStringFromHTML?.runs.count == 1) + #expect(attributedStringFromHTML?.link == nil) } - func testHyperlinksAreIgnoredInCode() { + @Test + func hyperlinksAreIgnoredInCode() { let htmlString = "
test matrix test
" let attributedStringFromHTML = attributedStringBuilder.fromHTML(htmlString) - XCTAssertNil(attributedStringFromHTML?.link) + #expect(attributedStringFromHTML?.link == nil) } - func testUserMentionIsIgnoredInCode() { + @Test + func userMentionIsIgnoredInCode() { let htmlString = "
test https://matrix.org/#/@test:matrix.org test
" let attributedString = attributedStringBuilder.fromHTML(htmlString) - XCTAssert(attributedString?.runs.count == 1) + #expect(attributedString?.runs.count == 1) - XCTAssertNil(attributedString?.attachment) + #expect(attributedString?.attachment == nil) } - func testPlainTextUserMentionIsIgnoredInCode() { + @Test + func plainTextUserMentionIsIgnoredInCode() { let htmlString = "
Hey @some.user.ceriu:matrix.org
" let attributedString = attributedStringBuilder.fromHTML(htmlString) - XCTAssert(attributedString?.runs.count == 1) + #expect(attributedString?.runs.count == 1) - XCTAssertNil(attributedString?.attachment) + #expect(attributedString?.attachment == nil) } - func testAllUsersIsIgnoredInCode() { + @Test + func allUsersIsIgnoredInCode() { let htmlString = "
test @room test
" let attributedString = attributedStringBuilder.fromHTML(htmlString) - XCTAssert(attributedString?.runs.count == 1) + #expect(attributedString?.runs.count == 1) - XCTAssertNil(attributedString?.attachment) + #expect(attributedString?.attachment == nil) } - func testMultipleMentions() { - guard let url = URL(string: "https://matrix.to/#/@test:matrix.org") else { - XCTFail("Invalid url") - return - } + @Test + func multipleMentions() throws { + let url = try #require(URL(string: "https://matrix.to/#/@test:matrix.org"), "Invalid url") let string = "Hello @room, but especially hello to you \(url)" - guard let attributedStringFromHTML = attributedStringBuilder.fromHTML(string) else { - XCTFail("Attributed string is nil") - return - } + let attributedStringFromHTML = try #require(attributedStringBuilder.fromHTML(string), "Attributed string is nil") var foundAttachments = 0 var foundLink: URL? @@ -613,13 +578,10 @@ class AttributedStringBuilderTests: XCTestCase { foundLink = link } } - XCTAssertEqual(foundLink, url) - XCTAssertEqual(foundAttachments, 2) + #expect(foundLink == url) + #expect(foundAttachments == 2) - guard let attributedStringFromPlain = attributedStringBuilder.fromPlain(string) else { - XCTFail("Attributed string is nil") - return - } + let attributedStringFromPlain = try #require(attributedStringBuilder.fromPlain(string), "Attributed string is nil") foundAttachments = 0 foundLink = nil @@ -632,21 +594,16 @@ class AttributedStringBuilderTests: XCTestCase { foundLink = link } } - XCTAssertEqual(foundLink, url) - XCTAssertEqual(foundAttachments, 2) + #expect(foundLink == url) + #expect(foundAttachments == 2) } - func testMultipleMentions2() { - guard let url = URL(string: "https://matrix.to/#/@test:matrix.org") else { - XCTFail("Invalid url") - return - } + @Test + func multipleMentions2() throws { + let url = try #require(URL(string: "https://matrix.to/#/@test:matrix.org"), "Invalid url") let string = "\(url) @room" - guard let attributedStringFromHTML = attributedStringBuilder.fromHTML(string) else { - XCTFail("Attributed string is nil") - return - } + let attributedStringFromHTML = try #require(attributedStringBuilder.fromHTML(string), "Attributed string is nil") var foundAttachments = 0 var foundLink: URL? @@ -659,13 +616,10 @@ class AttributedStringBuilderTests: XCTestCase { foundLink = link } } - XCTAssertEqual(foundLink, url) - XCTAssertEqual(foundAttachments, 2) + #expect(foundLink == url) + #expect(foundAttachments == 2) - guard let attributedStringFromPlain = attributedStringBuilder.fromPlain(string) else { - XCTFail("Attributed string is nil") - return - } + let attributedStringFromPlain = try #require(attributedStringBuilder.fromPlain(string), "Attributed string is nil") foundAttachments = 0 foundLink = nil @@ -678,100 +632,85 @@ class AttributedStringBuilderTests: XCTestCase { foundLink = link } } - XCTAssertEqual(foundLink, url) - XCTAssertEqual(foundAttachments, 2) + #expect(foundLink == url) + #expect(foundAttachments == 2) } - func testImageTags() { + @Test + func imageTags() throws { let htmlString = "Hey \"Smiley! How's work?" - guard let attributedString = attributedStringBuilder.fromHTML(htmlString) else { - XCTFail("Could not build the attributed string") - return - } + let attributedString = try #require(attributedStringBuilder.fromHTML(htmlString), "Could not build the attributed string") - XCTAssertEqual(String(attributedString.characters), "Hey [img: Smiley face]! How's work[img]?") + #expect(String(attributedString.characters) == "Hey [img: Smiley face]! How's work[img]?") } - func testListTags() { + @Test + func listTags() throws { let htmlString = "

like

\n
    \n
  • this
    \ntest
  • \n
\n" - guard let attributedString = attributedStringBuilder.fromHTML(htmlString) else { - XCTFail("Could not build the attributed string") - return - } + let attributedString = try #require(attributedStringBuilder.fromHTML(htmlString), "Could not build the attributed string") - XCTAssertEqual(String(attributedString.characters), "like\n • this\ntest") + #expect(String(attributedString.characters) == "like\n • this\ntest") } - func testUnorderedList() { + @Test + func unorderedList() throws { let htmlString = "
  • 1
  • 2
  • 3
" - guard let attributedString = attributedStringBuilder.fromHTML(htmlString) else { - XCTFail("Could not build the attributed string") - return - } + let attributedString = try #require(attributedStringBuilder.fromHTML(htmlString), "Could not build the attributed string") - XCTAssertEqual(String(attributedString.characters), " • 1\n • 2\n • 3") + #expect(String(attributedString.characters) == " • 1\n • 2\n • 3") } - func testNestedUnorderedList() { + @Test + func nestedUnorderedList() throws { let htmlString = "
  • A
    • A1
    • A2
    • A3
  • B
  • C
" - guard let attributedString = attributedStringBuilder.fromHTML(htmlString) else { - XCTFail("Could not build the attributed string") - return - } + let attributedString = try #require(attributedStringBuilder.fromHTML(htmlString), "Could not build the attributed string") - XCTAssertEqual(String(attributedString.characters), " • A\n • A1\n • A2\n • A3\n • B\n • C") + #expect(String(attributedString.characters) == " • A\n • A1\n • A2\n • A3\n • B\n • C") } - func testOrderedList() { + @Test + func orderedList() throws { let htmlString = "
  1. 1
  2. 2
  3. 3
" - guard let attributedString = attributedStringBuilder.fromHTML(htmlString) else { - XCTFail("Could not build the attributed string") - return - } + let attributedString = try #require(attributedStringBuilder.fromHTML(htmlString), "Could not build the attributed string") - XCTAssertEqual(String(attributedString.characters), " 1. 1\n 2. 2\n 3. 3") + #expect(String(attributedString.characters) == " 1. 1\n 2. 2\n 3. 3") } - func testNestedOrderedList() { + @Test + func nestedOrderedList() throws { let htmlString = "
  1. A
    1. A1
    2. A2
    3. A3
  2. B
  3. C
" - guard let attributedString = attributedStringBuilder.fromHTML(htmlString) else { - XCTFail("Could not build the attributed string") - return - } + let attributedString = try #require(attributedStringBuilder.fromHTML(htmlString), "Could not build the attributed string") - XCTAssertEqual(String(attributedString.characters), " 1. A\n 1. A1\n 2. A2\n 3. A3\n 2. B\n 3. C") + #expect(String(attributedString.characters) == " 1. A\n 1. A1\n 2. A2\n 3. A3\n 2. B\n 3. C") } - func testOutOfOrderListNubmering() { + @Test + func outOfOrderListNubmering() throws { let htmlString = "
    \n
  1. this is a two
  2. \n
" - guard let attributedString = attributedStringBuilder.fromHTML(htmlString) else { - XCTFail("Could not build the attributed string") - return - } + let attributedString = try #require(attributedStringBuilder.fromHTML(htmlString), "Could not build the attributed string") - XCTAssertEqual(String(attributedString.characters), " 2. this is a two") + #expect(String(attributedString.characters) == " 2. this is a two") } - func testNestedHeterogeneousLists() { + @Test + func nestedHeterogeneousLists() throws { let htmlString = "
  1. A
    • A1
    • A2
    • A3
  2. B
  3. C
" - guard let attributedString = attributedStringBuilder.fromHTML(htmlString) else { - XCTFail("Could not build the attributed string") - return - } + let attributedString = try #require(attributedStringBuilder.fromHTML(htmlString), "Could not build the attributed string") - XCTAssertEqual(String(attributedString.characters), " 1. A\n • A1\n • A2\n • A3\n 2. B\n 3. C") + #expect(String(attributedString.characters) == " 1. A\n • A1\n • A2\n • A3\n 2. B\n 3. C") } /// https://github.com/element-hq/element-x-ios/issues/4856 - func testNormalisedWhitespaces() { + @Test + func normalisedWhitespaces() throws { let html = """ Stefan pushed 2 commits @@ -786,389 +725,292 @@ class AttributedStringBuilderTests: XCTestCase { """ - guard let attributedString = attributedStringBuilder.fromHTML(html) else { - XCTFail("Could not build the attributed string") - return - } + let attributedString = try #require(attributedStringBuilder.fromHTML(html), "Could not build the attributed string") - XCTAssertEqual(String(attributedString.characters), "Stefan pushed 2 commits to main:\n • Some update \n • Some other update") + #expect(String(attributedString.characters) == "Stefan pushed 2 commits to main:\n • Some update \n • Some other update") } // MARK: - Phishing prevention - func testPhishingLink() { + @Test + func phishingLink() throws { let htmlString = "Hey check the following link https://element.io" - guard let attributedString = attributedStringBuilder.fromHTML(htmlString) else { - XCTFail("Could not build the attributed string") - return - } + let attributedString = try #require(attributedStringBuilder.fromHTML(htmlString), "Could not build the attributed string") - XCTAssertEqual(String(attributedString.characters), "Hey check the following link https://element.io") + #expect(String(attributedString.characters) == "Hey check the following link https://element.io") - XCTAssertEqual(attributedString.runs.count, 2) + #expect(attributedString.runs.count == 2) - guard let link = attributedString.runs.first(where: { $0.link != nil })?.link else { - XCTFail("Couldn't find the link") - return - } - XCTAssertTrue(link.requiresConfirmation) - XCTAssertEqual(link.confirmationParameters?.internalURL.absoluteString, "https://matrix.org") - XCTAssertEqual(link.confirmationParameters?.displayString, "https://element.io") + let link = try #require(attributedString.runs.first { $0.link != nil }?.link, "Couldn't find the link") + #expect(link.requiresConfirmation) + #expect(link.confirmationParameters?.internalURL.absoluteString == "https://matrix.org") + #expect(link.confirmationParameters?.displayString == "https://element.io") } - func testValidLink() { + @Test + func validLink() throws { let htmlString = "Hey check the following link" - guard let attributedString = attributedStringBuilder.fromHTML(htmlString) else { - XCTFail("Could not build the attributed string") - return - } + let attributedString = try #require(attributedStringBuilder.fromHTML(htmlString), "Could not build the attributed string") - guard let link = attributedString.runs.first(where: { $0.link != nil })?.link else { - XCTFail("Couldn't find the link") - return - } - XCTAssertFalse(link.requiresConfirmation) - XCTAssertEqual(link.absoluteString, "https://matrix.org") + let link = try #require(attributedString.runs.first { $0.link != nil }?.link, "Couldn't find the link") + #expect(!link.requiresConfirmation) + #expect(link.absoluteString == "https://matrix.org") } - func testValidLinkWithRTLOverride() { + @Test + func validLinkWithRTLOverride() throws { let htmlString = "\u{202E}https://matrix.org" - guard let attributedString = attributedStringBuilder.fromHTML(htmlString) else { - XCTFail("Could not build the attributed string") - return - } + let attributedString = try #require(attributedStringBuilder.fromHTML(htmlString), "Could not build the attributed string") - guard let link = attributedString.runs.first(where: { $0.link != nil })?.link else { - XCTFail("Couldn't find the link") - return - } - XCTAssertFalse(link.requiresConfirmation) - XCTAssertEqual(link.absoluteString, "https://matrix.org") + let link = try #require(attributedString.runs.first { $0.link != nil }?.link, "Couldn't find the link") + #expect(!link.requiresConfirmation) + #expect(link.absoluteString == "https://matrix.org") } - func testPhishingUserID() { + @Test + func phishingUserID() throws { let htmlString = "Hey check the following user @alice:matrix.org" - guard let attributedString = attributedStringBuilder.fromHTML(htmlString) else { - XCTFail("Could not build the attributed string") - return - } + let attributedString = try #require(attributedStringBuilder.fromHTML(htmlString), "Could not build the attributed string") - XCTAssertEqual(String(attributedString.characters), "Hey check the following user @alice:matrix.org") + #expect(String(attributedString.characters) == "Hey check the following user @alice:matrix.org") - XCTAssertEqual(attributedString.runs.count, 2) + #expect(attributedString.runs.count == 2) - guard let link = attributedString.runs.first(where: { $0.link != nil })?.link else { - XCTFail("Couldn't find the link") - return - } - XCTAssertTrue(link.requiresConfirmation) - XCTAssertEqual(link.confirmationParameters?.internalURL.absoluteString, "https://matrix.org") - XCTAssertEqual(link.confirmationParameters?.displayString, "@alice:matrix.org") + let link = try #require(attributedString.runs.first { $0.link != nil }?.link, "Couldn't find the link") + #expect(link.requiresConfirmation) + #expect(link.confirmationParameters?.internalURL.absoluteString == "https://matrix.org") + #expect(link.confirmationParameters?.displayString == "@alice:matrix.org") } - func testValidUserIDLink() { + @Test + func validUserIDLink() throws { let htmlString = "Hey check the following user @alice:matrix.org" - guard let attributedString = attributedStringBuilder.fromHTML(htmlString) else { - XCTFail("Could not build the attributed string") - return - } + let attributedString = try #require(attributedStringBuilder.fromHTML(htmlString), "Could not build the attributed string") - checkAttachment(attributedString: attributedString, expectedRuns: 2) + try checkAttachment(attributedString: attributedString, expectedRuns: 2) - guard let link = attributedString.runs.first(where: { $0.link != nil })?.link else { - XCTFail("Couldn't find the link") - return - } - XCTAssertFalse(link.requiresConfirmation) - XCTAssertEqual(link.absoluteString, "https://matrix.to/#/@alice:matrix.org") + let link = try #require(attributedString.runs.first { $0.link != nil }?.link, "Couldn't find the link") + #expect(!link.requiresConfirmation) + #expect(link.absoluteString == "https://matrix.to/#/@alice:matrix.org") } - func testPhishingUserIDWithAnotherUserIDPermalink() { + @Test + func phishingUserIDWithAnotherUserIDPermalink() throws { let htmlString = "Hey check the following user @alice:matrix.org" - guard let attributedString = attributedStringBuilder.fromHTML(htmlString) else { - XCTFail("Could not build the attributed string") - return - } + let attributedString = try #require(attributedStringBuilder.fromHTML(htmlString), "Could not build the attributed string") - XCTAssertEqual(String(attributedString.characters), "Hey check the following user @alice:matrix.org") + #expect(String(attributedString.characters) == "Hey check the following user @alice:matrix.org") - XCTAssertEqual(attributedString.runs.count, 2) + #expect(attributedString.runs.count == 2) - guard let link = attributedString.runs.first(where: { $0.link != nil })?.link else { - XCTFail("Couldn't find the link") - return - } - XCTAssertTrue(link.requiresConfirmation) - XCTAssertEqual(link.confirmationParameters?.internalURL.absoluteString, "https://matrix.to/#/@bob:matrix.org") - XCTAssertEqual(link.confirmationParameters?.displayString, "@alice:matrix.org") + let link = try #require(attributedString.runs.first { $0.link != nil }?.link, "Couldn't find the link") + #expect(link.requiresConfirmation) + #expect(link.confirmationParameters?.internalURL.absoluteString == "https://matrix.to/#/@bob:matrix.org") + #expect(link.confirmationParameters?.displayString == "@alice:matrix.org") } - func testPhishingUserIDWithDistractingCharacters() { + @Test + func phishingUserIDWithDistractingCharacters() throws { let htmlString = "Hey check the following user 👉️ @alice:matrix.org" - guard let attributedString = attributedStringBuilder.fromHTML(htmlString) else { - XCTFail("Could not build the attributed string") - return - } + let attributedString = try #require(attributedStringBuilder.fromHTML(htmlString), "Could not build the attributed string") - XCTAssertEqual(String(attributedString.characters), "Hey check the following user 👉️ @alice:matrix.org") + #expect(String(attributedString.characters) == "Hey check the following user 👉️ @alice:matrix.org") - XCTAssertEqual(attributedString.runs.count, 2) + #expect(attributedString.runs.count == 2) - guard let link = attributedString.runs.first(where: { $0.link != nil })?.link else { - XCTFail("Couldn't find the link") - return - } - XCTAssertTrue(link.requiresConfirmation) - XCTAssertEqual(link.confirmationParameters?.internalURL.absoluteString, "https://matrix.org") - XCTAssertEqual(link.confirmationParameters?.displayString, "👉️ @alice:matrix.org") + let link = try #require(attributedString.runs.first { $0.link != nil }?.link, "Couldn't find the link") + #expect(link.requiresConfirmation) + #expect(link.confirmationParameters?.internalURL.absoluteString == "https://matrix.org") + #expect(link.confirmationParameters?.displayString == "👉️ @alice:matrix.org") } - func testPhishingLinkWithDistractingCharacters() { + @Test + func phishingLinkWithDistractingCharacters() throws { let htmlString = "Hey check the following link 👉️ https://element.io" - guard let attributedString = attributedStringBuilder.fromHTML(htmlString) else { - XCTFail("Could not build the attributed string") - return - } + let attributedString = try #require(attributedStringBuilder.fromHTML(htmlString), "Could not build the attributed string") - XCTAssertEqual(String(attributedString.characters), "Hey check the following link 👉️ https://element.io") + #expect(String(attributedString.characters) == "Hey check the following link 👉️ https://element.io") - XCTAssertEqual(attributedString.runs.count, 2) + #expect(attributedString.runs.count == 2) - guard let link = attributedString.runs.first(where: { $0.link != nil })?.link else { - XCTFail("Couldn't find the link") - return - } - XCTAssertTrue(link.requiresConfirmation) - XCTAssertEqual(link.confirmationParameters?.internalURL.absoluteString, "https://matrix.org") - XCTAssertEqual(link.confirmationParameters?.displayString, "👉️ https://element.io") + let link = try #require(attributedString.runs.first { $0.link != nil }?.link, "Couldn't find the link") + #expect(link.requiresConfirmation) + #expect(link.confirmationParameters?.internalURL.absoluteString == "https://matrix.org") + #expect(link.confirmationParameters?.displayString == "👉️ https://element.io") } - func testValidLinkWithDistractingCharacters() { + @Test + func validLinkWithDistractingCharacters() throws { let htmlString = "Hey check the following link 👉️ https://element.io" - guard let attributedString = attributedStringBuilder.fromHTML(htmlString) else { - XCTFail("Could not build the attributed string") - return - } - XCTAssertEqual(String(attributedString.characters), "Hey check the following link 👉️ https://element.io") + let attributedString = try #require(attributedStringBuilder.fromHTML(htmlString), "Could not build the attributed string") + #expect(String(attributedString.characters) == "Hey check the following link 👉️ https://element.io") - guard let link = attributedString.runs.first(where: { $0.link != nil })?.link else { - XCTFail("Couldn't find the link") - return - } + let link = try #require(attributedString.runs.first { $0.link != nil }?.link, "Couldn't find the link") - XCTAssertFalse(link.requiresConfirmation) - XCTAssertEqual(link.absoluteString, "https://element.io") + #expect(!link.requiresConfirmation) + #expect(link.absoluteString == "https://element.io") } - func testPhishingLinkWithFakeDotCharacter() { + @Test + func phishingLinkWithFakeDotCharacter() throws { let htmlString = "Hey check the following link https://element﹒io" - guard let attributedString = attributedStringBuilder.fromHTML(htmlString) else { - XCTFail("Could not build the attributed string") - return - } + let attributedString = try #require(attributedStringBuilder.fromHTML(htmlString), "Could not build the attributed string") - XCTAssertEqual(String(attributedString.characters), "Hey check the following link https://element﹒io") + #expect(String(attributedString.characters) == "Hey check the following link https://element﹒io") - XCTAssertEqual(attributedString.runs.count, 2) + #expect(attributedString.runs.count == 2) - guard let link = attributedString.runs.first(where: { $0.link != nil })?.link else { - XCTFail("Couldn't find the link") - return - } - XCTAssertTrue(link.requiresConfirmation) - XCTAssertEqual(link.confirmationParameters?.internalURL.absoluteString, "https://matrix.org") - XCTAssertEqual(link.confirmationParameters?.displayString, "https://element﹒io") + let link = try #require(attributedString.runs.first { $0.link != nil }?.link, "Couldn't find the link") + #expect(link.requiresConfirmation) + #expect(link.confirmationParameters?.internalURL.absoluteString == "https://matrix.org") + #expect(link.confirmationParameters?.displayString == "https://element﹒io") } - func testPhishingMatrixPermalinks() { + @Test + func phishingMatrixPermalinks() throws { let htmlString = "Hey check the following room https://matrix.to/#/#beautiful-room:matrix.org" - guard let attributedString = attributedStringBuilder.fromHTML(htmlString) else { - XCTFail("Could not build the attributed string") - return - } + let attributedString = try #require(attributedStringBuilder.fromHTML(htmlString), "Could not build the attributed string") - XCTAssertEqual(attributedString.runs.count, 2) + #expect(attributedString.runs.count == 2) - guard let link = attributedString.runs.first(where: { $0.link != nil })?.link else { - XCTFail("Couldn't find the link") - return - } + let link = try #require(attributedString.runs.first { $0.link != nil }?.link, "Couldn't find the link") - XCTAssertTrue(link.requiresConfirmation) - XCTAssertEqual(link.confirmationParameters?.internalURL.absoluteString, "https://matrix.to/#/%23offensive-room:matrix.org") - XCTAssertEqual(link.confirmationParameters?.displayString, "https://matrix.to/#/#beautiful-room:matrix.org") + #expect(link.requiresConfirmation) + #expect(link.confirmationParameters?.internalURL.absoluteString == "https://matrix.to/#/%23offensive-room:matrix.org") + #expect(link.confirmationParameters?.displayString == "https://matrix.to/#/#beautiful-room:matrix.org") } - func testValidMatrixPermalinks() { + @Test + func validMatrixPermalinks() throws { let htmlString = "Hey check the following room https://matrix.to/#/#beautiful-room:matrix.org" - guard let attributedString = attributedStringBuilder.fromHTML(htmlString) else { - XCTFail("Could not build the attributed string") - return - } + let attributedString = try #require(attributedStringBuilder.fromHTML(htmlString), "Could not build the attributed string") - checkAttachment(attributedString: attributedString, expectedRuns: 2) - guard let link = attributedString.runs.first(where: { $0.link != nil })?.link else { - XCTFail("Couldn't find the link") - return - } + try checkAttachment(attributedString: attributedString, expectedRuns: 2) + let link = try #require(attributedString.runs.first { $0.link != nil }?.link, "Couldn't find the link") - XCTAssertFalse(link.requiresConfirmation) - XCTAssertEqual(link.absoluteString, "https://matrix.to/#/%23beautiful-room:matrix.org") + #expect(!link.requiresConfirmation) + #expect(link.absoluteString == "https://matrix.to/#/%23beautiful-room:matrix.org") } - func testPhishingRoomAlias() { + @Test + func phishingRoomAlias() throws { let htmlString = "Hey check the following room #room:matrix.org" - guard let attributedString = attributedStringBuilder.fromHTML(htmlString) else { - XCTFail("Could not build the attributed string") - return - } + let attributedString = try #require(attributedStringBuilder.fromHTML(htmlString), "Could not build the attributed string") - XCTAssertEqual(String(attributedString.characters), "Hey check the following room #room:matrix.org") + #expect(String(attributedString.characters) == "Hey check the following room #room:matrix.org") - XCTAssertEqual(attributedString.runs.count, 2) + #expect(attributedString.runs.count == 2) - guard let link = attributedString.runs.first(where: { $0.link != nil })?.link else { - XCTFail("Couldn't find the link") - return - } - XCTAssertTrue(link.requiresConfirmation) - XCTAssertEqual(link.confirmationParameters?.internalURL.absoluteString, "https://matrix.org") - XCTAssertEqual(link.confirmationParameters?.displayString, "#room:matrix.org") + let link = try #require(attributedString.runs.first { $0.link != nil }?.link, "Couldn't find the link") + #expect(link.requiresConfirmation) + #expect(link.confirmationParameters?.internalURL.absoluteString == "https://matrix.org") + #expect(link.confirmationParameters?.displayString == "#room:matrix.org") } - func testValidRoomAliasLink() { + @Test + func validRoomAliasLink() throws { let htmlString = "Hey check the following user #room:matrix.org" - guard let attributedString = attributedStringBuilder.fromHTML(htmlString) else { - XCTFail("Could not build the attributed string") - return - } + let attributedString = try #require(attributedStringBuilder.fromHTML(htmlString), "Could not build the attributed string") - checkAttachment(attributedString: attributedString, expectedRuns: 2) + try checkAttachment(attributedString: attributedString, expectedRuns: 2) - guard let link = attributedString.runs.first(where: { $0.link != nil })?.link else { - XCTFail("Couldn't find the link") - return - } - XCTAssertFalse(link.requiresConfirmation) - XCTAssertEqual(link.absoluteString, "https://matrix.to/#/%23room:matrix.org") + let link = try #require(attributedString.runs.first { $0.link != nil }?.link, "Couldn't find the link") + #expect(!link.requiresConfirmation) + #expect(link.absoluteString == "https://matrix.to/#/%23room:matrix.org") } - func testPhishingRoomAliasWithAnotherRoomAliasPermalink() { + @Test + func phishingRoomAliasWithAnotherRoomAliasPermalink() throws { let htmlString = "Hey check the following room #room:matrix.org" - guard let attributedString = attributedStringBuilder.fromHTML(htmlString) else { - XCTFail("Could not build the attributed string") - return - } + let attributedString = try #require(attributedStringBuilder.fromHTML(htmlString), "Could not build the attributed string") - XCTAssertEqual(String(attributedString.characters), "Hey check the following room #room:matrix.org") + #expect(String(attributedString.characters) == "Hey check the following room #room:matrix.org") - XCTAssertEqual(attributedString.runs.count, 2) + #expect(attributedString.runs.count == 2) - guard let link = attributedString.runs.first(where: { $0.link != nil })?.link else { - XCTFail("Couldn't find the link") - return - } - XCTAssertTrue(link.requiresConfirmation) - XCTAssertEqual(link.confirmationParameters?.internalURL.absoluteString, "https://matrix.to/#/%23another-room:matrix.org") - XCTAssertEqual(link.confirmationParameters?.displayString, "#room:matrix.org") + let link = try #require(attributedString.runs.first { $0.link != nil }?.link, "Couldn't find the link") + #expect(link.requiresConfirmation) + #expect(link.confirmationParameters?.internalURL.absoluteString == "https://matrix.to/#/%23another-room:matrix.org") + #expect(link.confirmationParameters?.displayString == "#room:matrix.org") } - func testRoomAliasWithDistractingCharacters() { + @Test + func roomAliasWithDistractingCharacters() throws { let htmlString = "Hey check the following user 👉️ #room:matrix.org" - guard let attributedString = attributedStringBuilder.fromHTML(htmlString) else { - XCTFail("Could not build the attributed string") - return - } + let attributedString = try #require(attributedStringBuilder.fromHTML(htmlString), "Could not build the attributed string") - XCTAssertEqual(String(attributedString.characters), "Hey check the following user 👉️ #room:matrix.org") + #expect(String(attributedString.characters) == "Hey check the following user 👉️ #room:matrix.org") - XCTAssertEqual(attributedString.runs.count, 2) + #expect(attributedString.runs.count == 2) - guard let link = attributedString.runs.first(where: { $0.link != nil })?.link else { - XCTFail("Couldn't find the link") - return - } - XCTAssertTrue(link.requiresConfirmation) - XCTAssertEqual(link.confirmationParameters?.internalURL.absoluteString, "https://matrix.org") - XCTAssertEqual(link.confirmationParameters?.displayString, "👉️ #room:matrix.org") + let link = try #require(attributedString.runs.first { $0.link != nil }?.link, "Couldn't find the link") + #expect(link.requiresConfirmation) + #expect(link.confirmationParameters?.internalURL.absoluteString == "https://matrix.org") + #expect(link.confirmationParameters?.displayString == "👉️ #room:matrix.org") } - func testMxExternalPaymentDetailsRemoved() { + @Test + func mxExternalPaymentDetailsRemoved() throws { var htmlString = "This is visible. But this is hidden and this link too" - guard let attributedString = attributedStringBuilder.fromHTML(htmlString) else { - XCTFail("Could not build the attributed string") - return - } + let attributedString = try #require(attributedStringBuilder.fromHTML(htmlString), "Could not build the attributed string") - XCTAssertEqual(String(attributedString.characters), "This is visible.") + #expect(String(attributedString.characters) == "This is visible.") for run in attributedString.runs where run.link != nil { - XCTFail("No link expected, but found one") + Issue.record("No link expected, but found one") return } htmlString = "This is visible. And this text and link are visible too." - guard let attributedString = attributedStringBuilder.fromHTML(htmlString) else { - XCTFail("Could not build the attributed string") - return - } + let attributedString2 = try #require(attributedStringBuilder.fromHTML(htmlString), "Could not build the attributed string") - XCTAssertEqual(String(attributedString.characters), "This is visible. And this text and link are visible too.") + #expect(String(attributedString2.characters) == "This is visible. And this text and link are visible too.") - guard attributedString.runs.first(where: { $0.link != nil })?.link != nil else { - XCTFail("Couldn't find the link") - return - } + try #require(attributedString2.runs.first { $0.link != nil }?.link, "Couldn't find the link") } // MARK: - Private - private func checkLinkIn(attributedString: AttributedString?, expectedLink: String, expectedRuns: Int) { - guard let attributedString else { - XCTFail("Could not build the attributed string") - return - } + private func checkLinkIn(attributedString: AttributedString?, expectedLink: String, expectedRuns: Int) throws { + let attributedString = try #require(attributedString, "Could not build the attributed string") - XCTAssertEqual(attributedString.runs.count, expectedRuns) + #expect(attributedString.runs.count == expectedRuns) for run in attributedString.runs where run.link != nil { - XCTAssertEqual(run.link?.absoluteString, expectedLink) + #expect(run.link?.absoluteString == expectedLink) return } - XCTFail("Couldn't find expected value.") + Issue.record("Couldn't find expected value.") } - private func checkAttachment(attributedString: AttributedString?, expectedRuns: Int) { - guard let attributedString else { - XCTFail("Could not build the attributed string") - return - } + private func checkAttachment(attributedString: AttributedString?, expectedRuns: Int) throws { + let attributedString = try #require(attributedString, "Could not build the attributed string") - XCTAssertEqual(attributedString.runs.count, expectedRuns) + #expect(attributedString.runs.count == expectedRuns) for run in attributedString.runs where run.attachment != nil { return } - XCTFail("Couldn't find expected value.") + Issue.record("Couldn't find expected value.") } } diff --git a/UnitTests/Sources/BugReportServiceTests.swift b/UnitTests/Sources/BugReportServiceTests.swift index 59fa2cd63..8f1d2f66d 100644 --- a/UnitTests/Sources/BugReportServiceTests.swift +++ b/UnitTests/Sources/BugReportServiceTests.swift @@ -54,6 +54,7 @@ final class BugReportServiceTests { } @Test + @MainActor func initialStateWithRealService() { let urlPublisher: CurrentValueSubject = .init(.url("https://example.com/submit")) let service = BugReportService(rageshakeURLPublisher: urlPublisher.asCurrentValuePublisher(), diff --git a/UnitTests/Sources/ChatsTabFlowCoordinatorTests.swift b/UnitTests/Sources/ChatsTabFlowCoordinatorTests.swift index 5772b8dc9..22bb8eefe 100644 --- a/UnitTests/Sources/ChatsTabFlowCoordinatorTests.swift +++ b/UnitTests/Sources/ChatsTabFlowCoordinatorTests.swift @@ -8,10 +8,12 @@ import Combine @testable import ElementX -import XCTest +import Foundation +import Testing +@Suite @MainActor -class ChatsTabFlowCoordinatorTests: XCTestCase { +struct ChatsTabFlowCoordinatorTests { var clientProxy: ClientProxyMock! var timelineControllerFactory: TimelineControllerFactoryMock! var chatsTabFlowCoordinator: ChatsTabFlowCoordinator! @@ -22,15 +24,14 @@ class ChatsTabFlowCoordinatorTests: XCTestCase { var cancellables = Set() var detailCoordinator: CoordinatorProtocol? { - splitCoordinator?.detailCoordinator + splitCoordinator.detailCoordinator } var detailNavigationStack: NavigationStackCoordinator? { detailCoordinator as? NavigationStackCoordinator } - override func setUp() async throws { - cancellables.removeAll() + init() async throws { clientProxy = ClientProxyMock(.init(userID: "hi@bob", roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded(.mockRooms))))) timelineControllerFactory = TimelineControllerFactoryMock(.init()) @@ -60,224 +61,234 @@ class ChatsTabFlowCoordinatorTests: XCTestCase { try await deferred.fulfill() } - func testRoomPresentation() async throws { + @Test + mutating func roomPresentation() async throws { try await process(route: .room(roomID: "1", via: []), expectedState: .roomList(detailState: .room(roomID: "1"))) - XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) - XCTAssertNotNil(detailCoordinator) + #expect(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) + #expect(detailCoordinator != nil) try await process(route: .roomList, expectedState: .roomList(detailState: nil)) - XCTAssertNil(detailNavigationStack?.rootCoordinator) - XCTAssertNil(detailCoordinator) + #expect(detailNavigationStack?.rootCoordinator == nil) + #expect(detailCoordinator == nil) try await process(route: .room(roomID: "1", via: []), expectedState: .roomList(detailState: .room(roomID: "1"))) - XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) - XCTAssertNotNil(detailCoordinator) + #expect(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) + #expect(detailCoordinator != nil) try await process(route: .room(roomID: "2", via: []), expectedState: .roomList(detailState: .room(roomID: "2"))) - XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) - XCTAssertNotNil(detailCoordinator) + #expect(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) + #expect(detailCoordinator != nil) try await process(route: .roomList, expectedState: .roomList(detailState: nil)) - XCTAssertNil(detailNavigationStack?.rootCoordinator) - XCTAssertNil(detailCoordinator) + #expect(detailNavigationStack?.rootCoordinator == nil) + #expect(detailCoordinator == nil) - XCTAssertEqual(notificationManager.removeDeliveredMessageNotificationsForReceivedInvocations, ["1", "1", "2"]) + #expect(notificationManager.removeDeliveredMessageNotificationsForReceivedInvocations == ["1", "1", "2"]) } - func testRoomAliasPresentation() async throws { + @Test + mutating func roomAliasPresentation() async throws { clientProxy.resolveRoomAliasReturnValue = .success(.init(roomId: "1", servers: [])) try await process(route: .roomAlias("#alias:matrix.org"), expectedState: .roomList(detailState: .room(roomID: "1"))) - XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) - XCTAssertNotNil(detailCoordinator) + #expect(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) + #expect(detailCoordinator != nil) try await process(route: .roomList, expectedState: .roomList(detailState: nil)) - XCTAssertNil(detailNavigationStack?.rootCoordinator) - XCTAssertNil(detailCoordinator) + #expect(detailNavigationStack?.rootCoordinator == nil) + #expect(detailCoordinator == nil) try await process(route: .room(roomID: "1", via: []), expectedState: .roomList(detailState: .room(roomID: "1"))) - XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) - XCTAssertNotNil(detailCoordinator) + #expect(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) + #expect(detailCoordinator != nil) clientProxy.resolveRoomAliasReturnValue = .success(.init(roomId: "2", servers: [])) try await process(route: .room(roomID: "2", via: []), expectedState: .roomList(detailState: .room(roomID: "2"))) - XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) - XCTAssertNotNil(detailCoordinator) + #expect(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) + #expect(detailCoordinator != nil) try await process(route: .roomList, expectedState: .roomList(detailState: nil)) - XCTAssertNil(detailNavigationStack?.rootCoordinator) - XCTAssertNil(detailCoordinator) + #expect(detailNavigationStack?.rootCoordinator == nil) + #expect(detailCoordinator == nil) - XCTAssertEqual(notificationManager.removeDeliveredMessageNotificationsForReceivedInvocations, ["1", "1", "2"]) + #expect(notificationManager.removeDeliveredMessageNotificationsForReceivedInvocations == ["1", "1", "2"]) } - func testRoomDetailsPresentation() async throws { + @Test + mutating func roomDetailsPresentation() async throws { try await process(route: .roomDetails(roomID: "1"), expectedState: .roomList(detailState: .room(roomID: "1"))) - XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator) - XCTAssertNotNil(detailCoordinator) + #expect(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator) + #expect(detailCoordinator != nil) try await process(route: .roomList, expectedState: .roomList(detailState: nil)) - XCTAssertNil(detailNavigationStack?.rootCoordinator) - XCTAssertNil(detailCoordinator) + #expect(detailNavigationStack?.rootCoordinator == nil) + #expect(detailCoordinator == nil) } - func testStackUnwinding() async throws { + @Test + mutating func stackUnwinding() async throws { try await process(route: .roomDetails(roomID: "1"), expectedState: .roomList(detailState: .room(roomID: "1"))) - XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator) - XCTAssertNotNil(detailCoordinator) + #expect(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator) + #expect(detailCoordinator != nil) try await process(route: .room(roomID: "2", via: []), expectedState: .roomList(detailState: .room(roomID: "2"))) - XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) - XCTAssertNotNil(detailCoordinator) + #expect(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) + #expect(detailCoordinator != nil) } - func testNoOp() async throws { + @Test + mutating func noOp() async throws { try await process(route: .roomDetails(roomID: "1"), expectedState: .roomList(detailState: .room(roomID: "1"))) - XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator) - XCTAssertNotNil(detailCoordinator) + #expect(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator) + #expect(detailCoordinator != nil) - let unexpectedFulfillment = deferFailure(stateMachineFactory.chatsTabFlowStatePublisher, timeout: 1) { _ in true } + let unexpectedFulfillment = deferFailure(stateMachineFactory.chatsTabFlowStatePublisher, timeout: .seconds(1)) { _ in true } chatsTabFlowCoordinator.handleAppRoute(.roomDetails(roomID: "1"), animated: true) try await unexpectedFulfillment.fulfill() - XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator) - XCTAssertNotNil(detailCoordinator) + #expect(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator) + #expect(detailCoordinator != nil) } - func testSwitchToDifferentDetails() async throws { + @Test + mutating func switchToDifferentDetails() async throws { try await process(route: .roomDetails(roomID: "1"), expectedState: .roomList(detailState: .room(roomID: "1"))) - XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator) - XCTAssertNotNil(detailCoordinator) + #expect(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator) + #expect(detailCoordinator != nil) try await process(route: .roomDetails(roomID: "2"), expectedState: .roomList(detailState: .room(roomID: "2"))) - XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator) - XCTAssertNotNil(detailCoordinator) + #expect(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator) + #expect(detailCoordinator != nil) } - func testPushDetails() async throws { + @Test + mutating func pushDetails() async throws { try await process(route: .room(roomID: "1", via: []), expectedState: .roomList(detailState: .room(roomID: "1"))) - XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) - XCTAssertNotNil(detailCoordinator) + #expect(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) + #expect(detailCoordinator != nil) - let unexpectedFulfillment = deferFailure(stateMachineFactory.chatsTabFlowStatePublisher, timeout: 1) { _ in true } + let unexpectedFulfillment = deferFailure(stateMachineFactory.chatsTabFlowStatePublisher, timeout: .seconds(1)) { _ in true } chatsTabFlowCoordinator.handleAppRoute(.roomDetails(roomID: "1"), animated: true) try await unexpectedFulfillment.fulfill() - XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) - XCTAssertEqual(detailNavigationStack?.stackCoordinators.count, 1) - XCTAssertTrue(detailNavigationStack?.stackCoordinators.first is RoomDetailsScreenCoordinator) - XCTAssertNotNil(detailCoordinator) + #expect(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) + #expect(detailNavigationStack?.stackCoordinators.count == 1) + #expect(detailNavigationStack?.stackCoordinators.first is RoomDetailsScreenCoordinator) + #expect(detailCoordinator != nil) } - func testReplaceDetailsWithTimeline() async throws { + @Test + mutating func replaceDetailsWithTimeline() async throws { try await process(route: .roomDetails(roomID: "1"), expectedState: .roomList(detailState: .room(roomID: "1"))) - XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator) - XCTAssertNotNil(detailCoordinator) + #expect(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator) + #expect(detailCoordinator != nil) try await process(route: .room(roomID: "1", via: []), expectedState: .roomList(detailState: .room(roomID: "1"))) - XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) - XCTAssertNotNil(detailCoordinator) + #expect(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) + #expect(detailCoordinator != nil) } - func testUserProfileClearsStack() async throws { + @Test + mutating func userProfileClearsStack() async throws { try await process(route: .roomDetails(roomID: "1"), expectedState: .roomList(detailState: .room(roomID: "1"))) - XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator) - XCTAssertNotNil(detailCoordinator) - XCTAssertNil(splitCoordinator?.sheetCoordinator) + #expect(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator) + #expect(detailCoordinator != nil) + #expect(splitCoordinator.sheetCoordinator == nil) try await process(route: .userProfile(userID: "alice"), expectedState: .userProfileScreen) - XCTAssertNil(detailNavigationStack?.rootCoordinator) - guard let sheetStackCoordinator = splitCoordinator?.sheetCoordinator as? NavigationStackCoordinator else { - XCTFail("There should be a navigation stack presented as a sheet.") - return - } - XCTAssertTrue(sheetStackCoordinator.rootCoordinator is UserProfileScreenCoordinator) + #expect(detailNavigationStack?.rootCoordinator == nil) + let sheetStackCoordinator = try #require(splitCoordinator.sheetCoordinator as? NavigationStackCoordinator, "There should be a navigation stack presented as a sheet.") + #expect(sheetStackCoordinator.rootCoordinator is UserProfileScreenCoordinator) } - func testRoomClearsStack() async throws { + @Test + mutating func roomClearsStack() async throws { try await process(route: .room(roomID: "1", via: []), expectedState: .roomList(detailState: .room(roomID: "1"))) - XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) - XCTAssertEqual(detailNavigationStack?.stackCoordinators.count, 0) - XCTAssertNotNil(detailCoordinator) + #expect(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) + #expect(detailNavigationStack?.stackCoordinators.count == 0) + #expect(detailCoordinator != nil) chatsTabFlowCoordinator.handleAppRoute(.childRoom(roomID: "2", via: []), animated: true) try await Task.sleep(for: .milliseconds(100)) - XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) - XCTAssertEqual(detailNavigationStack?.stackCoordinators.count, 1) - XCTAssertTrue(detailNavigationStack?.stackCoordinators.first is RoomScreenCoordinator) - XCTAssertNotNil(detailCoordinator) + #expect(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) + #expect(detailNavigationStack?.stackCoordinators.count == 1) + #expect(detailNavigationStack?.stackCoordinators.first is RoomScreenCoordinator) + #expect(detailCoordinator != nil) try await process(route: .room(roomID: "3", via: []), expectedState: .roomList(detailState: .room(roomID: "3"))) - XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) - XCTAssertEqual(detailNavigationStack?.stackCoordinators.count, 0) - XCTAssertNotNil(detailCoordinator) + #expect(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) + #expect(detailNavigationStack?.stackCoordinators.count == 0) + #expect(detailCoordinator != nil) } - func testEventRoutes() async throws { + @Test + mutating func eventRoutes() async throws { // A regular event route should set its room as the root of the stack and focus on the event. try await process(route: .event(eventID: "1", roomID: "1", via: []), expectedState: .roomList(detailState: .room(roomID: "1"))) - XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) - XCTAssertEqual(detailNavigationStack?.stackCoordinators.count, 0) - XCTAssertNotNil(detailCoordinator) - XCTAssertEqual(timelineControllerFactory.buildTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderCallsCount, 1) - XCTAssertEqual(timelineControllerFactory.buildTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderReceivedArguments?.initialFocussedEventID, "1") + #expect(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) + #expect(detailNavigationStack?.stackCoordinators.count == 0) + #expect(detailCoordinator != nil) + #expect(timelineControllerFactory.buildTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderCallsCount == 1) + #expect(timelineControllerFactory.buildTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderReceivedArguments?.initialFocussedEventID == "1") // A child event route should push a new room screen onto the stack and focus on the event. chatsTabFlowCoordinator.handleAppRoute(.childEvent(eventID: "2", roomID: "2", via: []), animated: true) try await Task.sleep(for: .milliseconds(100)) - XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) - XCTAssertEqual(detailNavigationStack?.stackCoordinators.count, 1) - XCTAssertTrue(detailNavigationStack?.stackCoordinators.first is RoomScreenCoordinator) - XCTAssertNotNil(detailCoordinator) - XCTAssertEqual(timelineControllerFactory.buildTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderCallsCount, 2) - XCTAssertEqual(timelineControllerFactory.buildTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderReceivedArguments?.initialFocussedEventID, "2") + #expect(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) + #expect(detailNavigationStack?.stackCoordinators.count == 1) + #expect(detailNavigationStack?.stackCoordinators.first is RoomScreenCoordinator) + #expect(detailCoordinator != nil) + #expect(timelineControllerFactory.buildTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderCallsCount == 2) + #expect(timelineControllerFactory.buildTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderReceivedArguments?.initialFocussedEventID == "2") // A subsequent regular event route should clear the stack and set the new room as the root of the stack. try await process(route: .event(eventID: "3", roomID: "3", via: []), expectedState: .roomList(detailState: .room(roomID: "3"))) - XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) - XCTAssertEqual(detailNavigationStack?.stackCoordinators.count, 0) - XCTAssertNotNil(detailCoordinator) - XCTAssertEqual(timelineControllerFactory.buildTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderCallsCount, 3) - XCTAssertEqual(timelineControllerFactory.buildTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderReceivedArguments?.initialFocussedEventID, "3") + #expect(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) + #expect(detailNavigationStack?.stackCoordinators.count == 0) + #expect(detailCoordinator != nil) + #expect(timelineControllerFactory.buildTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderCallsCount == 3) + #expect(timelineControllerFactory.buildTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderReceivedArguments?.initialFocussedEventID == "3") // A regular event route for the same room should set a new instance of the room as the root of the stack. try await process(route: .event(eventID: "4", roomID: "3", via: []), expectedState: .roomList(detailState: .room(roomID: "3"))) - XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) - XCTAssertEqual(detailNavigationStack?.stackCoordinators.count, 0) - XCTAssertNotNil(detailCoordinator) - XCTAssertEqual(timelineControllerFactory.buildTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderCallsCount, 4) - XCTAssertEqual(timelineControllerFactory.buildTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderReceivedArguments?.initialFocussedEventID, "4", - "A new timeline should be created for the same room ID, so that the screen isn't stale while loading.") + #expect(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) + #expect(detailNavigationStack?.stackCoordinators.count == 0) + #expect(detailCoordinator != nil) + #expect(timelineControllerFactory.buildTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderCallsCount == 4) + #expect(timelineControllerFactory.buildTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderReceivedArguments?.initialFocussedEventID == "4", + "A new timeline should be created for the same room ID, so that the screen isn't stale while loading.") } - func testShareMediaRouteWithRoom() async throws { + @Test + mutating func shareMediaRouteWithRoom() async throws { try await process(route: .event(eventID: "1", roomID: "1", via: []), expectedState: .roomList(detailState: .room(roomID: "1"))) - XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) + #expect(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) let sharePayload: ShareExtensionPayload = .mediaFiles(roomID: "2", mediaFiles: [.init(url: .picturesDirectory, suggestedName: nil)]) try await process(route: .share(sharePayload), expectedState: .roomList(detailState: .room(roomID: "2"))) - XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) - XCTAssertTrue((splitCoordinator?.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is MediaUploadPreviewScreenCoordinator) + #expect(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) + #expect((splitCoordinator.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is MediaUploadPreviewScreenCoordinator) } - func testShareTextRouteWithRoom() async throws { + @Test + mutating func shareTextRouteWithRoom() async throws { try await process(route: .event(eventID: "1", roomID: "1", via: []), expectedState: .roomList(detailState: .room(roomID: "1"))) - XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) + #expect(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) let sharePayload: ShareExtensionPayload = .text(roomID: "2", text: "Important text") try await process(route: .share(sharePayload), expectedState: .roomList(detailState: .room(roomID: "2"))) - XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) - XCTAssertNil(splitCoordinator?.sheetCoordinator, "The media upload sheet shouldn't be shown when sharing text.") + #expect(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) + #expect(splitCoordinator.sheetCoordinator == nil, "The media upload sheet shouldn't be shown when sharing text.") } // MARK: - Private - private func process(route: AppRoute, expectedState: ChatsTabFlowCoordinatorStateMachine.State) async throws { + private mutating func process(route: AppRoute, expectedState: ChatsTabFlowCoordinatorStateMachine.State) async throws { // Sometimes the state machine's state changes before the coordinators have updated the stack. let delayedPublisher = stateMachineFactory.chatsTabFlowStatePublisher.delay(for: .milliseconds(100), scheduler: DispatchQueue.main) diff --git a/UnitTests/Sources/CompletionSuggestionServiceTests.swift b/UnitTests/Sources/CompletionSuggestionServiceTests.swift index 38bb7195b..64f7ef0e0 100644 --- a/UnitTests/Sources/CompletionSuggestionServiceTests.swift +++ b/UnitTests/Sources/CompletionSuggestionServiceTests.swift @@ -8,17 +8,13 @@ import Combine @testable import ElementX -import XCTest +import Testing +@Suite @MainActor -final class CompletionSuggestionServiceTests: XCTestCase { - private var cancellables = Set() - - override func setUp() { - cancellables.removeAll() - } - - func testUserSuggestions() async throws { +struct CompletionSuggestionServiceTests { + @Test + func userSuggestions() async throws { let alice: RoomMemberProxyMock = .mockAlice let members: [RoomMemberProxyMock] = [alice, .mockBob, .mockCharlie, .mockMe] let roomProxyMock = JoinedRoomProxyMock(.init(id: "roomID", name: "test", members: members)) @@ -57,7 +53,8 @@ final class CompletionSuggestionServiceTests: XCTestCase { try await deferred.fulfill() } - func testUserSuggestionsIncludingAllUsers() async throws { + @Test + func userSuggestionsIncludingAllUsers() async throws { let alice: RoomMemberProxyMock = .mockAlice let members: [RoomMemberProxyMock] = [alice, .mockBob, .mockCharlie, .mockMe] let roomProxyMock = JoinedRoomProxyMock(.init(id: "roomID", @@ -88,7 +85,8 @@ final class CompletionSuggestionServiceTests: XCTestCase { try await deferred.fulfill() } - func testUserSuggestionsWithEmptyText() async throws { + @Test + func userSuggestionsWithEmptyText() async throws { let alice: RoomMemberProxyMock = .mockAlice let bob: RoomMemberProxyMock = .mockBob let members: [RoomMemberProxyMock] = [alice, bob, .mockMe] @@ -124,7 +122,8 @@ final class CompletionSuggestionServiceTests: XCTestCase { try await deferred.fulfill() } - func testUserSuggestionInDifferentMessagePositions() async throws { + @Test + func userSuggestionInDifferentMessagePositions() async throws { let alice: RoomMemberProxyMock = .mockAlice let members: [RoomMemberProxyMock] = [alice, .mockBob, .mockCharlie, .mockMe] let roomProxyMock = JoinedRoomProxyMock(.init(name: "test", members: members)) @@ -151,7 +150,8 @@ final class CompletionSuggestionServiceTests: XCTestCase { try await deferred.fulfill() } - func testUserSuggestionWithMultipleMentionSymbol() async throws { + @Test + func userSuggestionWithMultipleMentionSymbol() async throws { let alice: RoomMemberProxyMock = .mockAlice let bob: RoomMemberProxyMock = .mockBob let members: [RoomMemberProxyMock] = [alice, bob, .mockCharlie, .mockMe] @@ -179,7 +179,8 @@ final class CompletionSuggestionServiceTests: XCTestCase { try await deffered.fulfill() } - func testRoomSuggestions() async throws { + @Test + func roomSuggestions() async throws { let alice: RoomMemberProxyMock = .mockAlice // We keep the users in the tests since they should not appear in the suggestions when using the room trigger let members: [RoomMemberProxyMock] = [alice, .mockBob, .mockCharlie, .mockMe] @@ -252,7 +253,8 @@ final class CompletionSuggestionServiceTests: XCTestCase { try await deferred.fulfill() } - func testRoomSuggestionInDifferentMessagePositions() async throws { + @Test + func roomSuggestionInDifferentMessagePositions() async throws { let alice: RoomMemberProxyMock = .mockAlice // We keep the users in the tests since they should not appear in the suggestions when using the room trigger let members: [RoomMemberProxyMock] = [alice, .mockBob, .mockCharlie, .mockMe] @@ -301,7 +303,8 @@ final class CompletionSuggestionServiceTests: XCTestCase { try await deferred.fulfill() } - func testRoomSuggestionWithMultipleMentionSymbol() async throws { + @Test + func roomSuggestionWithMultipleMentionSymbol() async throws { let alice: RoomMemberProxyMock = .mockAlice // We keep the users in the tests since they should not appear in the suggestions when using the room trigger let members: [RoomMemberProxyMock] = [alice, .mockBob, .mockCharlie, .mockMe] @@ -351,7 +354,8 @@ final class CompletionSuggestionServiceTests: XCTestCase { try await deffered.fulfill() } - func testSuggestionsWithMultipleDifferentTriggers() async throws { + @Test + func suggestionsWithMultipleDifferentTriggers() async throws { let alice: RoomMemberProxyMock = .mockAlice // We keep the users in the tests since they should not appear in the suggestions when using the room trigger let members: [RoomMemberProxyMock] = [alice, .mockBob, .mockCharlie, .mockMe] @@ -380,7 +384,8 @@ final class CompletionSuggestionServiceTests: XCTestCase { try await deffered.fulfill() } - func testSuggestionsContainingNonAlphanumericCharacters() async throws { + @Test + func suggestionsContainingNonAlphanumericCharacters() async throws { let alice: RoomMemberProxyMock = .mockAlice // We keep the users in the tests since they should not appear in the suggestions when using the room trigger let members: [RoomMemberProxyMock] = [alice, .mockBob, .mockCharlie, .mockMe] diff --git a/UnitTests/Sources/ComposerToolbarViewModelTests.swift b/UnitTests/Sources/ComposerToolbarViewModelTests.swift index eb8288399..3175c9b6b 100644 --- a/UnitTests/Sources/ComposerToolbarViewModelTests.swift +++ b/UnitTests/Sources/ComposerToolbarViewModelTests.swift @@ -8,89 +8,86 @@ import Combine @testable import ElementX +import Foundation import MatrixRustSDK +import Testing import WysiwygComposer -import XCTest +@Suite @MainActor -class ComposerToolbarViewModelTests: XCTestCase { +final class ComposerToolbarViewModelTests { private var appSettings: AppSettings! private var wysiwygViewModel: WysiwygComposerViewModel! private var viewModel: ComposerToolbarViewModel! private var completionSuggestionServiceMock: CompletionSuggestionServiceMock! private var draftServiceMock: ComposerDraftServiceMock! - override func setUp() { + init() { AppSettings.resetAllSettings() appSettings = AppSettings() ServiceLocator.shared.register(appSettings: appSettings) setUpViewModel() } - override func tearDown() { + deinit { AppSettings.resetAllSettings() } - func testComposerFocus() { + @Test + func composerFocus() { viewModel.process(timelineAction: .setMode(mode: .edit(originalEventOrTransactionID: .eventID("mock"), type: .default))) - XCTAssertTrue(viewModel.state.bindings.composerFocused) + #expect(viewModel.state.bindings.composerFocused) viewModel.process(timelineAction: .removeFocus) - XCTAssertFalse(viewModel.state.bindings.composerFocused) + #expect(!viewModel.state.bindings.composerFocused) } - func testComposerMode() { + @Test + func composerMode() { let mode: ComposerMode = .edit(originalEventOrTransactionID: .eventID("mock"), type: .default) viewModel.process(timelineAction: .setMode(mode: mode)) - XCTAssertEqual(viewModel.state.composerMode, mode) + #expect(viewModel.state.composerMode == mode) viewModel.process(timelineAction: .clear) - XCTAssertEqual(viewModel.state.composerMode, .default) + #expect(viewModel.state.composerMode == .default) } - func testComposerModeIsPublished() { + @Test + func composerModeIsPublished() async throws { let mode: ComposerMode = .edit(originalEventOrTransactionID: .eventID("mock"), type: .default) - let expectation = expectation(description: "Composer mode is published") - let cancellable = viewModel - .context - .$viewState - .map(\.composerMode) - .removeDuplicates() - .dropFirst() - .sink { composerMode in - XCTAssertEqual(composerMode, mode) - expectation.fulfill() - } - + let deferred = deferFulfillment(viewModel.context.$viewState.map(\.composerMode).removeDuplicates().dropFirst()) { $0 == mode } viewModel.process(timelineAction: .setMode(mode: mode)) - - wait(for: [expectation], timeout: 2.0) - cancellable.cancel() + try await deferred.fulfill() } - func testHandleKeyCommand() { - XCTAssertTrue(viewModel.context.viewState.keyCommands.count == 1) + @Test + func handleKeyCommand() { + #expect(viewModel.context.viewState.keyCommands.count == 1) } - func testComposerFocusAfterEnablingRTE() { + @Test + func composerFocusAfterEnablingRTE() { viewModel.process(viewAction: .enableTextFormatting) - XCTAssertTrue(viewModel.state.bindings.composerFocused) + #expect(viewModel.state.bindings.composerFocused) } - func testRTEEnabledAfterSendingMessage() { + @Test + func rteEnabledAfterSendingMessage() { viewModel.process(viewAction: .enableTextFormatting) - XCTAssertTrue(viewModel.state.bindings.composerFocused) + #expect(viewModel.state.bindings.composerFocused) viewModel.state.composerEmpty = false viewModel.process(viewAction: .sendMessage) - XCTAssertTrue(viewModel.state.bindings.composerFormattingEnabled) + #expect(viewModel.state.bindings.composerFormattingEnabled) } - func testAlertIsShownAfterLinkAction() { - XCTAssertNil(viewModel.state.bindings.alertInfo) + @Test + func alertIsShownAfterLinkAction() { + #expect(viewModel.state.bindings.alertInfo == nil) viewModel.process(viewAction: .enableTextFormatting) viewModel.process(viewAction: .composerAction(action: .link)) - XCTAssertNotNil(viewModel.state.bindings.alertInfo) + #expect(viewModel.state.bindings.alertInfo != nil) } - func testSuggestions() { + @Test + func suggestions() { let suggestions: [SuggestionItem] = [.init(suggestionType: .user(.init(id: "@user_mention_1:matrix.org", displayName: "User 1", avatarURL: nil)), range: .init(), rawSuggestionText: ""), .init(suggestionType: .user(.init(id: "@user_mention_2:matrix.org", displayName: "User 2", avatarURL: nil)), range: .init(), rawSuggestionText: "")] let mockCompletionSuggestionService = CompletionSuggestionServiceMock(configuration: .init(suggestions: suggestions)) @@ -104,31 +101,34 @@ class ComposerToolbarViewModelTests: XCTestCase { analyticsService: ServiceLocator.shared.analytics, composerDraftService: draftServiceMock) - XCTAssertEqual(viewModel.state.suggestions, suggestions) + #expect(viewModel.state.suggestions == suggestions) } - func testSuggestionTrigger() async throws { + @Test + func suggestionTrigger() async throws { let deferred = deferFulfillment(wysiwygViewModel.$attributedContent) { $0.plainText == "#room-alias-test" } wysiwygViewModel.setMarkdownContent("@user-test") wysiwygViewModel.setMarkdownContent("#room-alias-test") try await deferred.fulfill() // The first one is nil because when initialised the view model is empty - XCTAssertEqual(completionSuggestionServiceMock.setSuggestionTriggerReceivedInvocations, [nil, - .init(type: .user, text: "user-test", range: .init(location: 0, length: 10)), - .init(type: .room, text: "room-alias-test", - range: .init(location: 0, length: 16))]) + #expect(completionSuggestionServiceMock.setSuggestionTriggerReceivedInvocations == [nil, + .init(type: .user, text: "user-test", range: .init(location: 0, length: 10)), + .init(type: .room, text: "room-alias-test", + range: .init(location: 0, length: 16))]) } - func testSelectedUserSuggestion() { + @Test + func selectedUserSuggestion() { let suggestion = SuggestionItem(suggestionType: .user(.init(id: "@test:matrix.org", displayName: "Test", avatarURL: nil)), range: .init(), rawSuggestionText: "") viewModel.context.send(viewAction: .selectedSuggestion(suggestion)) // The display name can be used for HTML injection in the rich text editor and it's useless anyway as the clients don't use it when resolving display names - XCTAssertEqual(wysiwygViewModel.content.html, "@test:matrix.org ") + #expect(wysiwygViewModel.content.html == "@test:matrix.org ") } - func testSelectedRoomSuggestion() { + @Test + func selectedRoomSuggestion() { let suggestion = SuggestionItem(suggestionType: .room(.init(id: "!room:matrix.org", canonicalAlias: "#room-alias:matrix.org", name: "Room", @@ -140,19 +140,21 @@ class ComposerToolbarViewModelTests: XCTestCase { // The display name can be used for HTML injection in the rich text editor and it's useless anyway as the clients don't use it when resolving display names - XCTAssertEqual(wysiwygViewModel.content.html, "#room-alias:matrix.org ") + #expect(wysiwygViewModel.content.html == "#room-alias:matrix.org ") } - func testAllUsersSuggestion() throws { + @Test + func allUsersSuggestion() throws { let suggestion = SuggestionItem(suggestionType: .allUsers(.room(id: "", name: nil, avatarURL: nil)), range: .init(), rawSuggestionText: "") viewModel.context.send(viewAction: .selectedSuggestion(suggestion)) var string = "@room" - try string.unicodeScalars.append(XCTUnwrap(UnicodeScalar(String.nbsp))) - XCTAssertEqual(wysiwygViewModel.content.html, string) + try string.unicodeScalars.append(#require(UnicodeScalar(String.nbsp))) + #expect(wysiwygViewModel.content.html == string) } - func testUserMentionPillInRTE() async { + @Test + func userMentionPillInRTE() async { viewModel.context.send(viewAction: .composerAppeared) await Task.yield() let userID = "@test:matrix.org" @@ -160,10 +162,11 @@ class ComposerToolbarViewModelTests: XCTestCase { viewModel.context.send(viewAction: .selectedSuggestion(suggestion)) let attachment = wysiwygViewModel.textView.attributedText.attribute(.attachment, at: 0, effectiveRange: nil) as? PillTextAttachment - XCTAssertEqual(attachment?.pillData?.type, .user(userID: userID)) + #expect(attachment?.pillData?.type == .user(userID: userID)) } - func testRoomMentionPillInRTE() async { + @Test + func roomMentionPillInRTE() async { viewModel.context.send(viewAction: .composerAppeared) await Task.yield() let roomAlias = "#test:matrix.org" @@ -171,20 +174,22 @@ class ComposerToolbarViewModelTests: XCTestCase { viewModel.context.send(viewAction: .selectedSuggestion(suggestion)) let attachment = wysiwygViewModel.textView.attributedText.attribute(.attachment, at: 0, effectiveRange: nil) as? PillTextAttachment - XCTAssertEqual(attachment?.pillData?.type, .roomAlias(roomAlias)) + #expect(attachment?.pillData?.type == .roomAlias(roomAlias)) } - func testAllUsersMentionPillInRTE() async { + @Test + func allUsersMentionPillInRTE() async { viewModel.context.send(viewAction: .composerAppeared) await Task.yield() let suggestion = SuggestionItem(suggestionType: .allUsers(.room(id: "", name: nil, avatarURL: nil)), range: .init(), rawSuggestionText: "") viewModel.context.send(viewAction: .selectedSuggestion(suggestion)) let attachment = wysiwygViewModel.textView.attributedText.attribute(.attachment, at: 0, effectiveRange: nil) as? PillTextAttachment - XCTAssertEqual(attachment?.pillData?.type, .allUsers) + #expect(attachment?.pillData?.type == .allUsers) } - func testIntentionalMentions() async throws { + @Test + func intentionalMentions() async throws { wysiwygViewModel.setHtmlContent("""

Hello @room \ and especially hello to Test

@@ -205,77 +210,81 @@ class ComposerToolbarViewModelTests: XCTestCase { // MARK: - Draft - func testSaveDraftPlainText() async { - let expectation = expectation(description: "Wait for draft to be saved") - draftServiceMock.saveDraftClosure = { draft in - XCTAssertEqual(draft.plainText, "Hello world!") - XCTAssertNil(draft.htmlText) - XCTAssertEqual(draft.draftType, .newMessage) - defer { expectation.fulfill() } - return .success(()) - } - + @Test + func saveDraftPlainText() async throws { viewModel.context.composerFormattingEnabled = false viewModel.context.plainComposerText = .init(string: "Hello world!") - viewModel.saveDraft() - await fulfillment(of: [expectation], timeout: 10) - XCTAssertEqual(draftServiceMock.saveDraftCallsCount, 1) - XCTAssertFalse(draftServiceMock.clearDraftCalled) - XCTAssertFalse(draftServiceMock.loadDraftCalled) - } - - func testSaveDraftFormattedText() async { - let expectation = expectation(description: "Wait for draft to be saved") - draftServiceMock.saveDraftClosure = { draft in - XCTAssertEqual(draft.plainText, "__Hello__ world!") - XCTAssertEqual(draft.htmlText, "Hello world!") - XCTAssertEqual(draft.draftType, .newMessage) - defer { expectation.fulfill() } - return .success(()) + var capturedDraft: ComposerDraftProxy? + await waitForConfirmation("Save draft") { confirmation in + draftServiceMock.saveDraftClosure = { draft in + capturedDraft = draft + confirmation() + return .success(()) + } + viewModel.saveDraft() } + let draft = try #require(capturedDraft) + #expect(draft.plainText == "Hello world!") + #expect(draft.htmlText == nil) + #expect(draft.draftType == .newMessage) + #expect(draftServiceMock.saveDraftCallsCount == 1) + #expect(!draftServiceMock.clearDraftCalled) + #expect(!draftServiceMock.loadDraftCalled) + } + + @Test + func saveDraftFormattedText() async throws { viewModel.context.composerFormattingEnabled = true wysiwygViewModel.setHtmlContent("Hello world!") - viewModel.saveDraft() - await fulfillment(of: [expectation], timeout: 10) - XCTAssertEqual(draftServiceMock.saveDraftCallsCount, 1) - XCTAssertFalse(draftServiceMock.clearDraftCalled) - XCTAssertFalse(draftServiceMock.loadDraftCalled) - } - - func testSaveDraftEdit() async { - let expectation = expectation(description: "Wait for draft to be saved") - draftServiceMock.saveDraftClosure = { draft in - XCTAssertEqual(draft.plainText, "Hello world!") - XCTAssertNil(draft.htmlText) - XCTAssertEqual(draft.draftType, .edit(eventID: "testID")) - defer { expectation.fulfill() } - return .success(()) + var capturedDraft: ComposerDraftProxy? + await waitForConfirmation("Save draft") { confirmation in + draftServiceMock.saveDraftClosure = { draft in + capturedDraft = draft + confirmation() + return .success(()) + } + viewModel.saveDraft() } + let draft = try #require(capturedDraft) + #expect(draft.plainText == "__Hello__ world!") + #expect(draft.htmlText == "Hello world!") + #expect(draft.draftType == .newMessage) + #expect(draftServiceMock.saveDraftCallsCount == 1) + #expect(!draftServiceMock.clearDraftCalled) + #expect(!draftServiceMock.loadDraftCalled) + } + + @Test + func saveDraftEdit() async throws { viewModel.context.composerFormattingEnabled = false viewModel.process(timelineAction: .setMode(mode: .edit(originalEventOrTransactionID: .eventID("testID"), type: .default))) viewModel.context.plainComposerText = .init(string: "Hello world!") - viewModel.saveDraft() - await fulfillment(of: [expectation], timeout: 10) - XCTAssertEqual(draftServiceMock.saveDraftCallsCount, 1) - XCTAssertFalse(draftServiceMock.clearDraftCalled) - XCTAssertFalse(draftServiceMock.loadDraftCalled) - } - - func testSaveDraftReply() async { - let expectation = expectation(description: "Wait for draft to be saved") - draftServiceMock.saveDraftClosure = { draft in - XCTAssertEqual(draft.plainText, "Hello world!") - XCTAssertNil(draft.htmlText) - XCTAssertEqual(draft.draftType, .reply(eventID: "testID")) - defer { expectation.fulfill() } - return .success(()) + var capturedDraft: ComposerDraftProxy? + await waitForConfirmation("Save draft") { confirmation in + draftServiceMock.saveDraftClosure = { draft in + capturedDraft = draft + confirmation() + return .success(()) + } + viewModel.saveDraft() } + let draft = try #require(capturedDraft) + #expect(draft.plainText == "Hello world!") + #expect(draft.htmlText == nil) + #expect(draft.draftType == .edit(eventID: "testID")) + #expect(draftServiceMock.saveDraftCallsCount == 1) + #expect(!draftServiceMock.clearDraftCalled) + #expect(!draftServiceMock.loadDraftCalled) + } + + @Test + func saveDraftReply() async throws { viewModel.context.composerFormattingEnabled = false viewModel.process(timelineAction: .setMode(mode: .reply(eventID: "testID", replyDetails: .loaded(sender: .init(id: ""), @@ -283,143 +292,161 @@ class ComposerToolbarViewModelTests: XCTestCase { eventContent: .message(.text(.init(body: "reply text")))), isThread: false))) viewModel.context.plainComposerText = .init(string: "Hello world!") - viewModel.saveDraft() - await fulfillment(of: [expectation], timeout: 10) - XCTAssertEqual(draftServiceMock.saveDraftCallsCount, 1) - XCTAssertFalse(draftServiceMock.clearDraftCalled) - XCTAssertFalse(draftServiceMock.loadDraftCalled) - } - - func testSaveDraftWhenEmptyReply() async { - let expectation = expectation(description: "Wait for draft to be saved") - draftServiceMock.saveDraftClosure = { draft in - XCTAssertEqual(draft.plainText, "") - XCTAssertNil(draft.htmlText) - XCTAssertEqual(draft.draftType, .reply(eventID: "testID")) - defer { expectation.fulfill() } - return .success(()) + var capturedDraft: ComposerDraftProxy? + await waitForConfirmation("Save draft") { confirmation in + draftServiceMock.saveDraftClosure = { draft in + capturedDraft = draft + confirmation() + return .success(()) + } + viewModel.saveDraft() } + let draft = try #require(capturedDraft) + #expect(draft.plainText == "Hello world!") + #expect(draft.htmlText == nil) + #expect(draft.draftType == .reply(eventID: "testID")) + #expect(draftServiceMock.saveDraftCallsCount == 1) + #expect(!draftServiceMock.clearDraftCalled) + #expect(!draftServiceMock.loadDraftCalled) + } + + @Test + func saveDraftWhenEmptyReply() async throws { viewModel.context.composerFormattingEnabled = false viewModel.process(timelineAction: .setMode(mode: .reply(eventID: "testID", replyDetails: .loaded(sender: .init(id: ""), eventID: "testID", eventContent: .message(.text(.init(body: "reply text")))), isThread: false))) - viewModel.saveDraft() - await fulfillment(of: [expectation], timeout: 10) - XCTAssertEqual(draftServiceMock.saveDraftCallsCount, 1) - XCTAssertFalse(draftServiceMock.clearDraftCalled) - XCTAssertFalse(draftServiceMock.loadDraftCalled) - } - - func testClearDraftWhenEmptyNormalMessage() async { - let expectation = expectation(description: "Wait for draft to be cleared") - draftServiceMock.clearDraftClosure = { - defer { expectation.fulfill() } - return .success(()) + var capturedDraft: ComposerDraftProxy? + await waitForConfirmation("Save draft") { confirmation in + draftServiceMock.saveDraftClosure = { draft in + capturedDraft = draft + confirmation() + return .success(()) + } + viewModel.saveDraft() } + let draft = try #require(capturedDraft) + #expect(draft.plainText == "") + #expect(draft.htmlText == nil) + #expect(draft.draftType == .reply(eventID: "testID")) + #expect(draftServiceMock.saveDraftCallsCount == 1) + #expect(!draftServiceMock.clearDraftCalled) + #expect(!draftServiceMock.loadDraftCalled) + } + + @Test + func clearDraftWhenEmptyNormalMessage() async { viewModel.context.composerFormattingEnabled = false - viewModel.saveDraft() - await fulfillment(of: [expectation], timeout: 10) - XCTAssertFalse(draftServiceMock.saveDraftCalled) - XCTAssertEqual(draftServiceMock.clearDraftCallsCount, 1) - XCTAssertFalse(draftServiceMock.loadDraftCalled) - } - - func testClearDraftForNonTextMode() async { - let expectation = expectation(description: "Wait for draft to be cleared") - draftServiceMock.clearDraftClosure = { - defer { expectation.fulfill() } - return .success(()) + await waitForConfirmation("Clear draft") { confirmation in + draftServiceMock.clearDraftClosure = { + confirmation() + return .success(()) + } + viewModel.saveDraft() } + #expect(!draftServiceMock.saveDraftCalled) + #expect(draftServiceMock.clearDraftCallsCount == 1) + #expect(!draftServiceMock.loadDraftCalled) + } + + @Test + func clearDraftForNonTextMode() async { viewModel.context.composerFormattingEnabled = false let waveformData: [Float] = Array(repeating: 1.0, count: 1000) viewModel.context.plainComposerText = .init(string: "Hello world!") viewModel.process(timelineAction: .setMode(mode: .previewVoiceMessage(state: AudioPlayerState(id: .recorderPreview, title: "", duration: 10.0), waveform: .data(waveformData), isUploading: false))) - viewModel.saveDraft() - await fulfillment(of: [expectation], timeout: 10) - XCTAssertFalse(draftServiceMock.saveDraftCalled) - XCTAssertEqual(draftServiceMock.clearDraftCallsCount, 1) - XCTAssertFalse(draftServiceMock.loadDraftCalled) + await waitForConfirmation("Clear draft") { confirmation in + draftServiceMock.clearDraftClosure = { + confirmation() + return .success(()) + } + viewModel.saveDraft() + } + + #expect(!draftServiceMock.saveDraftCalled) + #expect(draftServiceMock.clearDraftCallsCount == 1) + #expect(!draftServiceMock.loadDraftCalled) } - func testNothingToRestore() async { + @Test + func nothingToRestore() async { viewModel.context.composerFormattingEnabled = false - let expectation = expectation(description: "Wait for draft to be restored") draftServiceMock.loadDraftClosure = { - defer { expectation.fulfill() } - return .success(nil) + .success(nil) } await viewModel.loadDraft() - await fulfillment(of: [expectation], timeout: 10) - XCTAssertFalse(viewModel.context.composerFormattingEnabled) - XCTAssertTrue(viewModel.state.composerEmpty) - XCTAssertEqual(viewModel.state.composerMode, .default) + #expect(!viewModel.context.composerFormattingEnabled) + #expect(viewModel.state.composerEmpty) + #expect(viewModel.state.composerMode == .default) } - func testRestoreNormalPlainTextMessage() async { + @Test + func restoreNormalPlainTextMessage() async { viewModel.context.composerFormattingEnabled = false - let expectation = expectation(description: "Wait for draft to be restored") draftServiceMock.loadDraftClosure = { - defer { expectation.fulfill() } - return .success(.init(plainText: "Hello world!", - htmlText: nil, - draftType: .newMessage)) + .success(.init(plainText: "Hello world!", + htmlText: nil, + draftType: .newMessage)) } await viewModel.loadDraft() - await fulfillment(of: [expectation], timeout: 10) - XCTAssertFalse(viewModel.context.composerFormattingEnabled) - XCTAssertEqual(viewModel.state.composerMode, .default) - XCTAssertEqual(viewModel.context.plainComposerText, NSAttributedString(string: "Hello world!")) + #expect(!viewModel.context.composerFormattingEnabled) + #expect(viewModel.state.composerMode == .default) + #expect(viewModel.context.plainComposerText == NSAttributedString(string: "Hello world!")) } - func testRestoreNormalFormattedTextMessage() async { + @Test + func restoreNormalFormattedTextMessage() async throws { + viewModel.context.composerFormattingEnabled = false + + try await confirmation { confirmation in + draftServiceMock.loadDraftClosure = { + defer { confirmation() } + return .success(.init(plainText: "__Hello__ world!", + htmlText: "Hello world!", + draftType: .newMessage)) + } + + let deferred = deferFulfillment(wysiwygViewModel.$isContentEmpty) { !$0 } + await viewModel.loadDraft() + try await deferred.fulfill() + } + + #expect(viewModel.context.composerFormattingEnabled) + #expect(viewModel.state.composerMode == .default) + #expect(wysiwygViewModel.content.html == "Hello world!") + #expect(wysiwygViewModel.content.markdown == "__Hello__ world!") + } + + @Test + func restoreEdit() async { viewModel.context.composerFormattingEnabled = false - let expectation = expectation(description: "Wait for draft to be restored") draftServiceMock.loadDraftClosure = { - defer { expectation.fulfill() } - return .success(.init(plainText: "__Hello__ world!", - htmlText: "Hello world!", - draftType: .newMessage)) + .success(.init(plainText: "Hello world!", + htmlText: nil, + draftType: .edit(eventID: "testID"))) } await viewModel.loadDraft() - await fulfillment(of: [expectation], timeout: 10) - XCTAssertTrue(viewModel.context.composerFormattingEnabled) - XCTAssertEqual(viewModel.state.composerMode, .default) - XCTAssertEqual(wysiwygViewModel.content.html, "Hello world!") - XCTAssertEqual(wysiwygViewModel.content.markdown, "__Hello__ world!") + #expect(!viewModel.context.composerFormattingEnabled) + #expect(viewModel.state.composerMode == .edit(originalEventOrTransactionID: .eventID("testID"), type: .default)) + #expect(viewModel.context.plainComposerText == NSAttributedString(string: "Hello world!")) } - func testRestoreEdit() async { - viewModel.context.composerFormattingEnabled = false - let expectation = expectation(description: "Wait for draft to be restored") - draftServiceMock.loadDraftClosure = { - defer { expectation.fulfill() } - return .success(.init(plainText: "Hello world!", - htmlText: nil, - draftType: .edit(eventID: "testID"))) - } - await viewModel.loadDraft() - - await fulfillment(of: [expectation], timeout: 10) - XCTAssertFalse(viewModel.context.composerFormattingEnabled) - XCTAssertEqual(viewModel.state.composerMode, .edit(originalEventOrTransactionID: .eventID("testID"), type: .default)) - XCTAssertEqual(viewModel.context.plainComposerText, NSAttributedString(string: "Hello world!")) - } - - func testRestoreReply() async { + @Test + func restoreReply() async throws { let testEventID = "testID" let text = "Hello world!" let loadedReply = TimelineItemReplyDetails.loaded(sender: .init(id: "userID", @@ -428,39 +455,38 @@ class ComposerToolbarViewModelTests: XCTestCase { eventContent: .message(.text(.init(body: "Reply text")))) viewModel.context.composerFormattingEnabled = false - let draftExpectation = expectation(description: "Wait for draft to be restored") draftServiceMock.loadDraftClosure = { - defer { draftExpectation.fulfill() } - return .success(.init(plainText: text, - htmlText: nil, - draftType: .reply(eventID: testEventID))) + .success(.init(plainText: text, + htmlText: nil, + draftType: .reply(eventID: testEventID))) } - let loadReplyExpectation = expectation(description: "Wait for reply to be loaded") + let deferredReplyLoaded = deferFulfillment(viewModel.context.$viewState) { + $0.composerMode == .reply(eventID: testEventID, replyDetails: loadedReply, isThread: true) + } draftServiceMock.getReplyEventIDClosure = { eventID in - defer { loadReplyExpectation.fulfill() } - XCTAssertEqual(eventID, testEventID) + #expect(eventID == testEventID) try? await Task.sleep(for: .seconds(1)) return .success(.init(details: loadedReply, isThreaded: true)) } await viewModel.loadDraft() - await fulfillment(of: [draftExpectation], timeout: 10) - XCTAssertFalse(viewModel.context.composerFormattingEnabled) + #expect(!viewModel.context.composerFormattingEnabled) // Testing the loading state first - XCTAssertEqual(viewModel.state.composerMode, .reply(eventID: testEventID, - replyDetails: .loading(eventID: testEventID), - isThread: false)) - XCTAssertEqual(viewModel.context.plainComposerText, NSAttributedString(string: text)) + #expect(viewModel.state.composerMode == .reply(eventID: testEventID, + replyDetails: .loading(eventID: testEventID), + isThread: false)) + #expect(viewModel.context.plainComposerText == NSAttributedString(string: text)) - await fulfillment(of: [loadReplyExpectation], timeout: 10) - XCTAssertEqual(viewModel.state.composerMode, .reply(eventID: testEventID, - replyDetails: loadedReply, - isThread: true)) + try await deferredReplyLoaded.fulfill() + #expect(viewModel.state.composerMode == .reply(eventID: testEventID, + replyDetails: loadedReply, + isThread: true)) } - func testRestoreReplyAndCancelReplyMode() async { + @Test + func restoreReplyAndCancelReplyMode() async throws { let testEventID = "testID" let text = "Hello world!" let loadedReply = TimelineItemReplyDetails.loaded(sender: .init(id: "userID", displayName: "Username"), @@ -468,103 +494,105 @@ class ComposerToolbarViewModelTests: XCTestCase { eventContent: .message(.text(.init(body: "Reply text")))) viewModel.context.composerFormattingEnabled = false - let draftExpectation = expectation(description: "Wait for draft to be restored") draftServiceMock.loadDraftClosure = { - defer { draftExpectation.fulfill() } - return .success(.init(plainText: text, - htmlText: nil, - draftType: .reply(eventID: testEventID))) + .success(.init(plainText: text, + htmlText: nil, + draftType: .reply(eventID: testEventID))) } - let loadReplyExpectation = expectation(description: "Wait for reply to be loaded") + let replyLoadedSubject = PassthroughSubject() + let deferredReplyLoaded = deferFulfillment(replyLoadedSubject) { _ in true } draftServiceMock.getReplyEventIDClosure = { eventID in - defer { loadReplyExpectation.fulfill() } - XCTAssertEqual(eventID, testEventID) + defer { replyLoadedSubject.send(()) } + #expect(eventID == testEventID) try? await Task.sleep(for: .seconds(1)) return .success(.init(details: loadedReply, isThreaded: true)) } await viewModel.loadDraft() - await fulfillment(of: [draftExpectation], timeout: 10) - XCTAssertFalse(viewModel.context.composerFormattingEnabled) + #expect(!viewModel.context.composerFormattingEnabled) // Testing the loading state first - XCTAssertEqual(viewModel.state.composerMode, .reply(eventID: testEventID, - replyDetails: .loading(eventID: testEventID), - isThread: false)) - XCTAssertEqual(viewModel.context.plainComposerText, NSAttributedString(string: text)) + #expect(viewModel.state.composerMode == .reply(eventID: testEventID, + replyDetails: .loading(eventID: testEventID), + isThread: false)) + #expect(viewModel.context.plainComposerText == NSAttributedString(string: text)) // Now we change the state to cancel the reply mode update viewModel.process(viewAction: .cancelReply) - await fulfillment(of: [loadReplyExpectation], timeout: 10) - XCTAssertEqual(viewModel.state.composerMode, .default) + try await deferredReplyLoaded.fulfill() + #expect(viewModel.state.composerMode == .default) } - func testSaveVolatileDraftWhenEditing() { + @Test + func saveVolatileDraftWhenEditing() { viewModel.context.composerFormattingEnabled = false viewModel.context.plainComposerText = .init(string: "Hello world!") viewModel.process(timelineAction: .setMode(mode: .edit(originalEventOrTransactionID: .eventID(UUID().uuidString), type: .default))) let draft = draftServiceMock.saveVolatileDraftReceivedDraft - XCTAssertNotNil(draft) - XCTAssertEqual(draft?.plainText, "Hello world!") - XCTAssertNil(draft?.htmlText) - XCTAssertEqual(draft?.draftType, .newMessage) + #expect(draft != nil) + #expect(draft?.plainText == "Hello world!") + #expect(draft?.htmlText == nil) + #expect(draft?.draftType == .newMessage) } - func testRestoreVolatileDraftWhenCancellingEdit() async { - let expectation = expectation(description: "Wait for draft to be restored") - draftServiceMock.loadVolatileDraftClosure = { - defer { expectation.fulfill() } - return .init(plainText: "Hello world", - htmlText: nil, - draftType: .newMessage) + @Test + func restoreVolatileDraftWhenCancellingEdit() async { + await waitForConfirmation("Volatile draft loaded") { confirmation in + draftServiceMock.loadVolatileDraftClosure = { + defer { confirmation() } + return .init(plainText: "Hello world", + htmlText: nil, + draftType: .newMessage) + } + DispatchQueue.main.async { + self.viewModel.process(viewAction: .cancelEdit) + } } - - viewModel.process(viewAction: .cancelEdit) - await fulfillment(of: [expectation]) - XCTAssertEqual(viewModel.context.plainComposerText, NSAttributedString(string: "Hello world")) + #expect(viewModel.context.plainComposerText == NSAttributedString(string: "Hello world")) } - func testRestoreVolatileDraftWhenClearing() async { - let expectation1 = expectation(description: "Wait for draft to be restored") - draftServiceMock.loadVolatileDraftClosure = { - defer { expectation1.fulfill() } - return .init(plainText: "Hello world", - htmlText: nil, - draftType: .newMessage) + @Test + func restoreVolatileDraftWhenClearing() async { + await waitForConfirmation("Volatile draft loaded and cleared", expectedCount: 2) { confirmation in + draftServiceMock.loadVolatileDraftClosure = { + defer { confirmation() } + return .init(plainText: "Hello world", + htmlText: nil, + draftType: .newMessage) + } + draftServiceMock.clearVolatileDraftClosure = { + confirmation() + } + DispatchQueue.main.async { + self.viewModel.process(timelineAction: .clear) + } } - - let expectation2 = expectation(description: "The draft should also be cleared after being loaded") - draftServiceMock.clearVolatileDraftClosure = { - expectation2.fulfill() - } - - viewModel.process(timelineAction: .clear) - await fulfillment(of: [expectation1, expectation2]) - XCTAssertEqual(viewModel.context.plainComposerText, NSAttributedString(string: "Hello world")) + #expect(viewModel.context.plainComposerText == NSAttributedString(string: "Hello world")) } - func testRestoreVolatileDraftDoubleClear() async { - let expectation1 = expectation(description: "Wait for draft to be restored") - draftServiceMock.loadVolatileDraftClosure = { - defer { expectation1.fulfill() } - return .init(plainText: "Hello world", - htmlText: nil, - draftType: .newMessage) + @Test + func restoreVolatileDraftDoubleClear() async { + await waitForConfirmation("Volatile draft loaded and cleared", expectedCount: 2) { confirmation in + draftServiceMock.loadVolatileDraftClosure = { + defer { confirmation() } + return .init(plainText: "Hello world", + htmlText: nil, + draftType: .newMessage) + } + draftServiceMock.clearVolatileDraftClosure = { + confirmation() + } + DispatchQueue.main.async { + self.viewModel.process(timelineAction: .clear) + } } - - let expectation2 = expectation(description: "The draft should also be cleared after being loaded") - draftServiceMock.clearVolatileDraftClosure = { - expectation2.fulfill() - } - - viewModel.process(timelineAction: .clear) - await fulfillment(of: [expectation1, expectation2]) - XCTAssertEqual(viewModel.context.plainComposerText, NSAttributedString(string: "Hello world")) + #expect(viewModel.context.plainComposerText == NSAttributedString(string: "Hello world")) } - func testRestoreUserMentionInPlainText() async throws { + @Test + func restoreUserMentionInPlainText() async throws { viewModel.context.composerFormattingEnabled = false let text = "Hello [TestName](https://matrix.to/#/@test:matrix.org)!" viewModel.process(timelineAction: .setText(plainText: text, htmlText: nil)) @@ -584,7 +612,8 @@ class ComposerToolbarViewModelTests: XCTestCase { try await deferred.fulfill() } - func testRestoreAllUsersMentionInPlainText() async throws { + @Test + func restoreAllUsersMentionInPlainText() async throws { viewModel.context.composerFormattingEnabled = false let text = "Hello @room" viewModel.process(timelineAction: .setText(plainText: text, htmlText: nil)) @@ -603,7 +632,8 @@ class ComposerToolbarViewModelTests: XCTestCase { try await deferred.fulfill() } - func testRestoreMixedMentionsInPlainText() async throws { + @Test + func restoreMixedMentionsInPlainText() async throws { viewModel.context.composerFormattingEnabled = false let text = "Hello [User1](https://matrix.to/#/@user1:matrix.org), [User2](https://matrix.to/#/@user2:matrix.org) and @room" viewModel.process(timelineAction: .setText(plainText: text, htmlText: nil)) @@ -623,7 +653,8 @@ class ComposerToolbarViewModelTests: XCTestCase { try await deferred.fulfill() } - func testRestoreAmbiguousMention() async throws { + @Test + func restoreAmbiguousMention() async throws { viewModel.context.composerFormattingEnabled = false let text = "Hello [User1](https://matrix.to/#/@roomuser:matrix.org)" viewModel.process(timelineAction: .setText(plainText: text, htmlText: nil)) @@ -643,12 +674,12 @@ class ComposerToolbarViewModelTests: XCTestCase { try await deferred.fulfill() } - func testRestoreDoesntOverwriteInitialText() async { + @Test + func restoreDoesntOverwriteInitialText() async { let sharedText = "Some shared text" - let expectation = expectation(description: "Wait for draft to be restored") - expectation.isInverted = true + var draftLoadCalled = false setUpViewModel(initialText: sharedText) { - defer { expectation.fulfill() } + draftLoadCalled = true return .success(.init(plainText: "Hello world!", htmlText: nil, draftType: .newMessage)) @@ -656,15 +687,16 @@ class ComposerToolbarViewModelTests: XCTestCase { viewModel.context.composerFormattingEnabled = false await viewModel.loadDraft() - await fulfillment(of: [expectation], timeout: 1) - XCTAssertFalse(viewModel.context.composerFormattingEnabled) - XCTAssertEqual(viewModel.state.composerMode, .default) - XCTAssertEqual(viewModel.context.plainComposerText, NSAttributedString(string: sharedText)) + #expect(!draftLoadCalled) + #expect(!viewModel.context.composerFormattingEnabled) + #expect(viewModel.state.composerMode == .default) + #expect(viewModel.context.plainComposerText == NSAttributedString(string: sharedText)) } // MARK: - Identity Violation - func testVerificationViolationDisablesComposer() async throws { + @Test + func verificationViolationDisablesComposer() async throws { let mockCompletionSuggestionService = CompletionSuggestionServiceMock(configuration: .init()) let roomProxyMock = JoinedRoomProxyMock(.init(name: "Test")) @@ -695,7 +727,8 @@ class ComposerToolbarViewModelTests: XCTestCase { try await fulfillment.fulfill() } - func testMultipleViolation() async throws { + @Test + func multipleViolation() async throws { let mockCompletionSuggestionService = CompletionSuggestionServiceMock(configuration: .init()) let roomProxyMock = JoinedRoomProxyMock(.init(name: "Test")) @@ -743,7 +776,8 @@ class ComposerToolbarViewModelTests: XCTestCase { try await fulfillment.fulfill() } - func testPinViolationDoesNotDisableComposer() { + @Test + func pinViolationDoesNotDisableComposer() async throws { let mockCompletionSuggestionService = CompletionSuggestionServiceMock(configuration: .init()) let roomProxyMock = JoinedRoomProxyMock(.init(name: "Test")) @@ -764,19 +798,8 @@ class ComposerToolbarViewModelTests: XCTestCase { analyticsService: ServiceLocator.shared.analytics, composerDraftService: draftServiceMock) - let expectation = expectation(description: "Composer should be enabled") - let cancellable = viewModel - .context - .$viewState - .map(\.canSend) - .sink { canSend in - if canSend { - expectation.fulfill() - } - } - - wait(for: [expectation], timeout: 2.0) - cancellable.cancel() + let deferred = deferFulfillment(viewModel.context.$viewState, message: "Composer should be enabled") { $0.canSend == true } + try await deferred.fulfill() } // MARK: - Helpers diff --git a/UnitTests/Sources/CreateRoomViewModelTests.swift b/UnitTests/Sources/CreateRoomViewModelTests.swift index b1b8d3f8d..4ddfa6cdf 100644 --- a/UnitTests/Sources/CreateRoomViewModelTests.swift +++ b/UnitTests/Sources/CreateRoomViewModelTests.swift @@ -8,10 +8,11 @@ import Combine @testable import ElementX -import XCTest +import Testing +@Suite @MainActor -class CreateRoomScreenViewModelTests: XCTestCase { +final class CreateRoomScreenViewModelTests { var viewModel: CreateRoomScreenViewModelProtocol! var clientProxy: ClientProxyMock! var spaceService: SpaceServiceProxyMock! @@ -23,7 +24,7 @@ class CreateRoomScreenViewModelTests: XCTestCase { viewModel.context } - override func tearDown() { + deinit { AppSettings.resetAllSettings() viewModel = nil clientProxy = nil @@ -31,28 +32,31 @@ class CreateRoomScreenViewModelTests: XCTestCase { userSession = nil } - func testDefaultState() { + @Test + func defaultState() { setup() - XCTAssertEqual(context.viewState.bindings.selectedAccessType, .private) - XCTAssertNil(context.selectedSpace) - XCTAssertEqual(context.viewState.availableAccessTypes, [.public, .askToJoin, .private]) - XCTAssertTrue(context.viewState.canSelectSpace) + #expect(context.viewState.bindings.selectedAccessType == .private) + #expect(context.selectedSpace == nil) + #expect(context.viewState.availableAccessTypes == [.public, .askToJoin, .private]) + #expect(context.viewState.canSelectSpace) } - func testCreateRoomRequirements() { + @Test + func createRoomRequirements() { setup() - XCTAssertFalse(context.viewState.canCreateRoom) + #expect(!context.viewState.canCreateRoom) context.send(viewAction: .updateRoomName("A")) - XCTAssertTrue(context.viewState.canCreateRoom) + #expect(context.viewState.canCreateRoom) } - func testCreateRoom() async throws { + @Test + func createRoom() async throws { setup() // Given a form with a blank topic. context.send(viewAction: .updateRoomName("A")) context.roomTopic = "" context.selectedAccessType = .private - XCTAssertTrue(context.viewState.canCreateRoom) + #expect(context.viewState.canCreateRoom) // When creating the room. clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReturnValue = .success("1") @@ -64,14 +68,15 @@ class CreateRoomScreenViewModelTests: XCTestCase { try await deferred.fulfill() // Then the room should be created and a topic should not be set. - XCTAssertTrue(clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartCalled) - XCTAssertEqual(clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReceivedArguments?.name, "A") - XCTAssertNil(clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReceivedArguments?.topic, - "The topic should be sent as nil when it is empty.") - XCTAssertEqual(clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReceivedArguments?.accessType, .private) + #expect(clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartCalled) + #expect(clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReceivedArguments?.name == "A") + #expect(clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReceivedArguments?.topic == nil, + "The topic should be sent as nil when it is empty.") + #expect(clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReceivedArguments?.accessType == .private) } - func testCreateSpace() async throws { + @Test + func createSpace() async throws { setup(isSpace: true) clientProxy = ClientProxyMock(.init(userIDServerName: "matrix.org", userID: "@a:b.com", @@ -92,7 +97,7 @@ class CreateRoomScreenViewModelTests: XCTestCase { context.send(viewAction: .updateRoomName("A")) context.roomTopic = "" context.selectedAccessType = .private - XCTAssertTrue(context.viewState.canCreateRoom) + #expect(context.viewState.canCreateRoom) // When creating the room. clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReturnValue = .success("1") @@ -106,14 +111,15 @@ class CreateRoomScreenViewModelTests: XCTestCase { try await deferred.fulfill() // Then the room should be created and a topic should not be set. - XCTAssertTrue(clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartCalled) - XCTAssertEqual(clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReceivedArguments?.name, "A") - XCTAssertNil(clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReceivedArguments?.topic, - "The topic should be sent as nil when it is empty.") - XCTAssertEqual(clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReceivedArguments?.accessType, .private) + #expect(clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartCalled) + #expect(clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReceivedArguments?.name == "A") + #expect(clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReceivedArguments?.topic == nil, + "The topic should be sent as nil when it is empty.") + #expect(clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReceivedArguments?.accessType == .private) } - func testCreateKnockingRoom() async { + @Test + func createKnockingRoom() async { setup() context.send(viewAction: .updateRoomName("A")) context.roomTopic = "B" @@ -121,20 +127,21 @@ class CreateRoomScreenViewModelTests: XCTestCase { // When setting the room as private we always reset the knocking state to the default value of false // so we need to wait a main actor cycle to ensure the view state is updated await Task.yield() - XCTAssertTrue(context.viewState.canCreateRoom) + #expect(context.viewState.canCreateRoom) - let expectation = expectation(description: "Wait for the room to be created") - clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartClosure = { _, _, accessType, _, _, _, localAliasPart in - XCTAssertEqual(accessType, .askToJoin) - XCTAssertEqual(localAliasPart, "a") - defer { expectation.fulfill() } - return .success("") + await waitForConfirmation("Wait for the room to be created") { confirmation in + clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartClosure = { _, _, accessType, _, _, _, localAliasPart in + #expect(accessType == .askToJoin) + #expect(localAliasPart == "a") + defer { confirmation() } + return .success("") + } + context.send(viewAction: .createRoom) } - context.send(viewAction: .createRoom) - await fulfillment(of: [expectation]) } - func testCreatePublicRoomFailsForInvalidAlias() async throws { + @Test + func createPublicRoomFailsForInvalidAlias() async throws { setup() context.send(viewAction: .updateRoomName("A")) context.roomTopic = "B" @@ -154,10 +161,11 @@ class CreateRoomScreenViewModelTests: XCTestCase { // blocked it context.send(viewAction: .createRoom) await Task.yield() - XCTAssertFalse(clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartCalled) + #expect(!clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartCalled) } - func testCreatePublicRoomFailsForExistingAlias() async throws { + @Test + func createPublicRoomFailsForExistingAlias() async throws { setup() clientProxy.isAliasAvailableReturnValue = .success(false) context.send(viewAction: .updateRoomName("A")) @@ -176,61 +184,61 @@ class CreateRoomScreenViewModelTests: XCTestCase { // We also want to force the room creation in case the user may tap the button before the debounce // blocked it - let expectation = expectation(description: "Wait for the alias to be checked again") - clientProxy.isAliasAvailableClosure = { _ in - defer { - expectation.fulfill() + await waitForConfirmation("Wait for the alias to be checked again") { confirmation in + clientProxy.isAliasAvailableClosure = { _ in + defer { confirmation() } + return .success(false) } - return .success(false) + context.send(viewAction: .createRoom) } - context.send(viewAction: .createRoom) - await fulfillment(of: [expectation]) - XCTAssertEqual(clientProxy.isAliasAvailableCallsCount, 2) - XCTAssertFalse(clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartCalled) + #expect(clientProxy.isAliasAvailableCallsCount == 2) + #expect(!clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartCalled) } - func testNameAndAddressSync() async { + @Test + func nameAndAddressSync() async { setup() context.selectedAccessType = .private await Task.yield() context.send(viewAction: .updateRoomName("abc")) - XCTAssertEqual(context.viewState.aliasLocalPart, "abc") - XCTAssertEqual(context.viewState.roomName, "abc") + #expect(context.viewState.aliasLocalPart == "abc") + #expect(context.viewState.roomName == "abc") context.send(viewAction: .updateRoomName("DEF")) - XCTAssertEqual(context.viewState.roomName, "DEF") - XCTAssertEqual(context.viewState.aliasLocalPart, "def") + #expect(context.viewState.roomName == "DEF") + #expect(context.viewState.aliasLocalPart == "def") context.send(viewAction: .updateRoomName("a b c")) - XCTAssertEqual(context.viewState.aliasLocalPart, "a-b-c") - XCTAssertEqual(context.viewState.roomName, "a b c") + #expect(context.viewState.aliasLocalPart == "a-b-c") + #expect(context.viewState.roomName == "a b c") context.send(viewAction: .updateAliasLocalPart("hello-world")) // This removes the sync - XCTAssertEqual(context.viewState.aliasLocalPart, "hello-world") - XCTAssertEqual(context.viewState.roomName, "a b c") + #expect(context.viewState.aliasLocalPart == "hello-world") + #expect(context.viewState.roomName == "a b c") context.send(viewAction: .updateRoomName("Hello Matrix!")) - XCTAssertEqual(context.viewState.aliasLocalPart, "hello-world") - XCTAssertEqual(context.viewState.roomName, "Hello Matrix!") + #expect(context.viewState.aliasLocalPart == "hello-world") + #expect(context.viewState.roomName == "Hello Matrix!") // Deleting the whole name will restore the sync context.send(viewAction: .updateRoomName("")) - XCTAssertEqual(context.viewState.aliasLocalPart, "") - XCTAssertEqual(context.viewState.roomName, "") + #expect(context.viewState.aliasLocalPart == "") + #expect(context.viewState.roomName == "") context.send(viewAction: .updateRoomName("Hello# Matrix!")) - XCTAssertEqual(context.viewState.aliasLocalPart, "hello-matrix!") - XCTAssertEqual(context.viewState.roomName, "Hello# Matrix!") + #expect(context.viewState.aliasLocalPart == "hello-matrix!") + #expect(context.viewState.roomName == "Hello# Matrix!") } - func testCreateRoomInASelectedSpaceFromTheList() async throws { + @Test + func createRoomInASelectedSpaceFromTheList() async throws { let spaces = [SpaceServiceRoom].mockJoinedSpaces2 setup() context.send(viewAction: .updateRoomName("A")) context.selectedAccessType = .public - XCTAssertTrue(context.viewState.canCreateRoom) - XCTAssertNil(context.selectedSpace) - XCTAssertEqual(context.viewState.availableAccessTypes, [.public, .askToJoin, .private]) - XCTAssertTrue(context.viewState.canSelectSpace) + #expect(context.viewState.canCreateRoom) + #expect(context.selectedSpace == nil) + #expect(context.viewState.availableAccessTypes == [.public, .askToJoin, .private]) + #expect(context.viewState.canSelectSpace) var deferred = deferFulfillment(context.$viewState) { viewState in viewState.editableSpaces.map(\.id) == spaces.map(\.id) @@ -248,64 +256,62 @@ class CreateRoomScreenViewModelTests: XCTestCase { // When creating the room. clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReturnValue = .success("1") - let expectation = expectation(description: "Wait for the addChild function to be called") - spaceService.addChildToClosure = { roomID, spaceID in - defer { expectation.fulfill() } - XCTAssertEqual(roomID, "1") - XCTAssertEqual(spaceID, spaces[0].id) - return .success(()) + try await confirmation("Wait for the addChild function to be called") { confirm in + let deferredAction = deferFulfillment(viewModel.actions) { action in + guard case .createdRoom(let roomProxy, nil) = action, roomProxy.id == "1" else { return false } + return true + } + spaceService.addChildToClosure = { roomID, spaceID in + defer { confirm() } + #expect(roomID == "1") + #expect(spaceID == spaces[0].id) + return .success(()) + } + context.send(viewAction: .createRoom) + try await deferredAction.fulfill() } - let deferredAction = deferFulfillment(viewModel.actions) { action in - guard case .createdRoom(let roomProxy, nil) = action, roomProxy.id == "1" else { return false } - return true - } - context.send(viewAction: .createRoom) - - await fulfillment(of: [expectation]) - try await deferredAction.fulfill() - - XCTAssertTrue(clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartCalled) - XCTAssertEqual(clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReceivedArguments?.name, "A") - XCTAssertEqual(clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReceivedArguments?.accessType, .private) + #expect(clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartCalled) + #expect(clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReceivedArguments?.name == "A") + #expect(clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReceivedArguments?.accessType == .private) } - func testCreateRoomInAnAlreadySelectedSpace() async throws { + @Test + func createRoomInAnAlreadySelectedSpace() async throws { let space = SpaceServiceRoom.mock(isSpace: true, joinRule: .invite) setup(spacesSelectionMode: .editableSpacesList(preSelectedSpace: space)) context.send(viewAction: .updateRoomName("A")) context.selectedAccessType = .spaceMembers - XCTAssertTrue(context.viewState.canCreateRoom) - XCTAssertEqual(context.selectedSpace?.id, space.id) - XCTAssertEqual(context.viewState.availableAccessTypes, [.spaceMembers, .askToJoinWithSpaceMembers, .private]) - XCTAssertTrue(context.viewState.canSelectSpace) + #expect(context.viewState.canCreateRoom) + #expect(context.selectedSpace?.id == space.id) + #expect(context.viewState.availableAccessTypes == [.spaceMembers, .askToJoinWithSpaceMembers, .private]) + #expect(context.viewState.canSelectSpace) // When creating the room. clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReturnValue = .success("1") - let expectation = expectation(description: "Wait for the addChild function to be called") - spaceService.addChildToClosure = { roomID, spaceID in - defer { expectation.fulfill() } - XCTAssertEqual(roomID, "1") - XCTAssertEqual(spaceID, space.id) - return .success(()) + try await confirmation("Wait for the addChild function to be called") { confirm in + let deferredAction = deferFulfillment(viewModel.actions) { action in + guard case .createdRoom(let roomProxy, nil) = action, roomProxy.id == "1" else { return false } + return true + } + spaceService.addChildToClosure = { roomID, spaceID in + defer { confirm() } + #expect(roomID == "1") + #expect(spaceID == space.id) + return .success(()) + } + context.send(viewAction: .createRoom) + try await deferredAction.fulfill() } - let deferredAction = deferFulfillment(viewModel.actions) { action in - guard case .createdRoom(let roomProxy, nil) = action, roomProxy.id == "1" else { return false } - return true - } - context.send(viewAction: .createRoom) - - await fulfillment(of: [expectation]) - try await deferredAction.fulfill() - - XCTAssertTrue(clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartCalled) - XCTAssertEqual(clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReceivedArguments?.name, "A") - XCTAssertEqual(clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReceivedArguments?.accessType, .spaceMembers(spaceID: space.id)) + #expect(clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartCalled) + #expect(clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReceivedArguments?.name == "A") + #expect(clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReceivedArguments?.accessType == .spaceMembers(spaceID: space.id)) } - func testCreateRoomInAnPublicSpaceAvailableTypes() { + @Test + func createRoomInAnPublicSpaceAvailableTypes() { let space = SpaceServiceRoom.mock(isSpace: true, joinRule: .public) setup(spacesSelectionMode: .editableSpacesList(preSelectedSpace: space)) @@ -313,10 +319,10 @@ class CreateRoomScreenViewModelTests: XCTestCase { context.send(viewAction: .updateRoomName("A")) context.roomTopic = "" context.selectedAccessType = .spaceMembers - XCTAssertTrue(context.viewState.canCreateRoom) - XCTAssertEqual(context.selectedSpace?.id, space.id) - XCTAssertEqual(context.viewState.availableAccessTypes, [.public, .askToJoin, .private]) - XCTAssertTrue(context.viewState.canSelectSpace) + #expect(context.viewState.canCreateRoom) + #expect(context.selectedSpace?.id == space.id) + #expect(context.viewState.availableAccessTypes == [.public, .askToJoin, .private]) + #expect(context.viewState.canSelectSpace) } private func setup(isSpace: Bool = false, spacesSelectionMode: CreateRoomScreenSpaceSelectionMode = .editableSpacesList(preSelectedSpace: nil)) { diff --git a/UnitTests/Sources/EditRoomAddressScreenViewModelTests.swift b/UnitTests/Sources/EditRoomAddressScreenViewModelTests.swift index a68469356..9968f24d5 100644 --- a/UnitTests/Sources/EditRoomAddressScreenViewModelTests.swift +++ b/UnitTests/Sources/EditRoomAddressScreenViewModelTests.swift @@ -7,17 +7,19 @@ // @testable import ElementX -import XCTest +import Testing +@Suite @MainActor -class EditRoomAddressScreenViewModelTests: XCTestCase { +struct EditRoomAddressScreenViewModelTests { var viewModel: EditRoomAddressScreenViewModelProtocol! var context: EditRoomAddressScreenViewModelType.Context { viewModel.context } - func testCanonicalAliasChosen() async throws { + @Test + mutating func canonicalAliasChosen() async throws { let roomProxy = JoinedRoomProxyMock(.init(name: "Room Name", canonicalAlias: "#room-name:matrix.org", alternativeAliases: ["#beta:homeserver.io", "#alternative-room-name:matrix.org"])) @@ -34,7 +36,8 @@ class EditRoomAddressScreenViewModelTests: XCTestCase { } /// Priority should be given to aliases from the current user's homeserver as they can edit those. - func testAlternativeAliasChosen() async throws { + @Test + mutating func alternativeAliasChosen() async throws { let roomProxy = JoinedRoomProxyMock(.init(name: "Room Name", canonicalAlias: "#alpha:homeserver.io", alternativeAliases: ["#beta:homeserver.io", "#room-name:matrix.org", @@ -51,7 +54,8 @@ class EditRoomAddressScreenViewModelTests: XCTestCase { try await deferred.fulfill() } - func testBuildAliasFromDisplayName() async throws { + @Test + mutating func buildAliasFromDisplayName() async throws { let roomProxy = JoinedRoomProxyMock(.init(name: "Room Name")) viewModel = EditRoomAddressScreenViewModel(roomProxy: roomProxy, @@ -65,7 +69,8 @@ class EditRoomAddressScreenViewModelTests: XCTestCase { try await deferred.fulfill() } - func testCorrectMethodsCalledOnSaveWhenNoAliasExists() async { + @Test + mutating func correctMethodsCalledOnSaveWhenNoAliasExists() async { let clientProxy = ClientProxyMock(.init(userIDServerName: "matrix.org")) clientProxy.isAliasAvailableReturnValue = .success(true) let roomProxy = JoinedRoomProxyMock(.init(name: "Room Name")) @@ -74,30 +79,33 @@ class EditRoomAddressScreenViewModelTests: XCTestCase { clientProxy: clientProxy, userIndicatorController: UserIndicatorControllerMock()) - XCTAssertNil(roomProxy.infoPublisher.value.canonicalAlias) - XCTAssertEqual(viewModel.context.viewState.bindings.desiredAliasLocalPart, "room-name") + #expect(roomProxy.infoPublisher.value.canonicalAlias == nil) + #expect(viewModel.context.viewState.bindings.desiredAliasLocalPart == "room-name") - let publishingExpectation = expectation(description: "Wait for publishing") - roomProxy.publishRoomAliasInRoomDirectoryClosure = { roomAlias in - defer { publishingExpectation.fulfill() } - XCTAssertEqual(roomAlias, "#room-name:matrix.org") - return .success(true) + await waitForConfirmation("Wait for save", expectedCount: 2) { confirm in + roomProxy.publishRoomAliasInRoomDirectoryClosure = { roomAlias in + #expect(roomAlias == "#room-name:matrix.org") + confirm() + return .success(true) + } + + roomProxy.updateCanonicalAliasAltAliasesClosure = { roomAlias, altAliases in + #expect(altAliases == []) + #expect(roomAlias == "#room-name:matrix.org") + confirm() + return .success(()) + } + + context.send(viewAction: .save) } - let updateAliasExpectation = expectation(description: "Wait for alias update") - roomProxy.updateCanonicalAliasAltAliasesClosure = { roomAlias, altAliases in - defer { updateAliasExpectation.fulfill() } - XCTAssertEqual(altAliases, []) - XCTAssertEqual(roomAlias, "#room-name:matrix.org") - return .success(()) - } - - context.send(viewAction: .save) - await fulfillment(of: [publishingExpectation, updateAliasExpectation], timeout: 1.0) - XCTAssertFalse(roomProxy.removeRoomAliasFromRoomDirectoryCalled) + #expect(roomProxy.publishRoomAliasInRoomDirectoryCalled) + #expect(roomProxy.updateCanonicalAliasAltAliasesCalled) + #expect(!roomProxy.removeRoomAliasFromRoomDirectoryCalled) } - func testCorrectMethodsCalledOnSaveWhenAliasOnSameHomeserverExists() async { + @Test + mutating func correctMethodsCalledOnSaveWhenAliasOnSameHomeserverExists() async { let clientProxy = ClientProxyMock(.init(userIDServerName: "matrix.org")) clientProxy.isAliasAvailableReturnValue = .success(true) let roomProxy = JoinedRoomProxyMock(.init(name: "Room Name", canonicalAlias: "#old-room-name:matrix.org")) @@ -108,33 +116,36 @@ class EditRoomAddressScreenViewModelTests: XCTestCase { context.desiredAliasLocalPart = "room-name" - let publishingExpectation = expectation(description: "Wait for publishing") - roomProxy.publishRoomAliasInRoomDirectoryClosure = { roomAlias in - defer { publishingExpectation.fulfill() } - XCTAssertEqual(roomAlias, "#room-name:matrix.org") - return .success(true) + await waitForConfirmation("Wait for save", expectedCount: 3) { confirm in + roomProxy.publishRoomAliasInRoomDirectoryClosure = { roomAlias in + #expect(roomAlias == "#room-name:matrix.org") + confirm() + return .success(true) + } + + roomProxy.updateCanonicalAliasAltAliasesClosure = { roomAlias, altAliases in + #expect(altAliases == []) + #expect(roomAlias == "#room-name:matrix.org") + confirm() + return .success(()) + } + + roomProxy.removeRoomAliasFromRoomDirectoryClosure = { roomAlias in + #expect(roomAlias == "#old-room-name:matrix.org") + confirm() + return .success(true) + } + + context.send(viewAction: .save) } - let updateAliasExpectation = expectation(description: "Wait for alias update") - roomProxy.updateCanonicalAliasAltAliasesClosure = { roomAlias, altAliases in - defer { updateAliasExpectation.fulfill() } - XCTAssertEqual(altAliases, []) - XCTAssertEqual(roomAlias, "#room-name:matrix.org") - return .success(()) - } - - let removeAliasExpectation = expectation(description: "Wait for alias removal") - roomProxy.removeRoomAliasFromRoomDirectoryClosure = { roomAlias in - defer { removeAliasExpectation.fulfill() } - XCTAssertEqual(roomAlias, "#old-room-name:matrix.org") - return .success(true) - } - - context.send(viewAction: .save) - await fulfillment(of: [publishingExpectation, updateAliasExpectation, removeAliasExpectation], timeout: 1.0) + #expect(roomProxy.publishRoomAliasInRoomDirectoryCalled) + #expect(roomProxy.updateCanonicalAliasAltAliasesCalled) + #expect(roomProxy.removeRoomAliasFromRoomDirectoryCalled) } - func testCorrectMethodsCalledOnSaveWhenAliasOnOtherHomeserverExists() async { + @Test + mutating func correctMethodsCalledOnSaveWhenAliasOnOtherHomeserverExists() async { let clientProxy = ClientProxyMock(.init(userIDServerName: "matrix.org")) clientProxy.isAliasAvailableReturnValue = .success(true) let roomProxy = JoinedRoomProxyMock(.init(name: "Room Name", canonicalAlias: "#old-room-name:element.io")) @@ -145,23 +156,25 @@ class EditRoomAddressScreenViewModelTests: XCTestCase { context.desiredAliasLocalPart = "room-name" - let publishingExpectation = expectation(description: "Wait for publishing") - roomProxy.publishRoomAliasInRoomDirectoryClosure = { roomAlias in - defer { publishingExpectation.fulfill() } - XCTAssertEqual(roomAlias, "#room-name:matrix.org") - return .success(true) + await waitForConfirmation("Wait for save", expectedCount: 2) { confirm in + roomProxy.publishRoomAliasInRoomDirectoryClosure = { roomAlias in + #expect(roomAlias == "#room-name:matrix.org") + confirm() + return .success(true) + } + + roomProxy.updateCanonicalAliasAltAliasesClosure = { roomAlias, altAliases in + #expect(altAliases == ["#room-name:matrix.org"]) + #expect(roomAlias == "#old-room-name:element.io") + confirm() + return .success(()) + } + + context.send(viewAction: .save) } - let updateAliasExpectation = expectation(description: "Wait for alias update") - roomProxy.updateCanonicalAliasAltAliasesClosure = { roomAlias, altAliases in - defer { updateAliasExpectation.fulfill() } - XCTAssertEqual(altAliases, ["#room-name:matrix.org"]) - XCTAssertEqual(roomAlias, "#old-room-name:element.io") - return .success(()) - } - - context.send(viewAction: .save) - await fulfillment(of: [publishingExpectation, updateAliasExpectation], timeout: 1.0) - XCTAssertFalse(roomProxy.removeRoomAliasFromRoomDirectoryCalled) + #expect(roomProxy.publishRoomAliasInRoomDirectoryCalled) + #expect(roomProxy.updateCanonicalAliasAltAliasesCalled) + #expect(!roomProxy.removeRoomAliasFromRoomDirectoryCalled) } } diff --git a/UnitTests/Sources/JoinRoomScreenViewModelTests.swift b/UnitTests/Sources/JoinRoomScreenViewModelTests.swift index c856683bd..7521df309 100644 --- a/UnitTests/Sources/JoinRoomScreenViewModelTests.swift +++ b/UnitTests/Sources/JoinRoomScreenViewModelTests.swift @@ -7,10 +7,11 @@ // @testable import ElementX -import XCTest +import Testing @MainActor -class JoinRoomScreenViewModelTests: XCTestCase { +@Suite +final class JoinRoomScreenViewModelTests { private enum TestMode { case joined case knocked @@ -27,74 +28,79 @@ class JoinRoomScreenViewModelTests: XCTestCase { viewModel.context } - override func setUp() { + init() { AppSettings.resetAllSettings() appSettings = AppSettings() ServiceLocator.shared.register(appSettings: appSettings) } - override func tearDown() { + deinit { viewModel = nil clientProxy = nil AppSettings.resetAllSettings() } - func testInteraction() async throws { - XCTAssertTrue(appSettings.seenInvites.isEmpty, "There shouldn't be any seen invites before running the tests.") + @Test + func interaction() async throws { + #expect(appSettings.seenInvites.isEmpty, "There shouldn't be any seen invites before running the tests.") setupViewModel() try await deferFulfillment(viewModel.context.$viewState) { $0.mode == .joinable }.fulfill() - XCTAssertTrue(appSettings.seenInvites.isEmpty, "Only an invited room should register the room ID as a seen invite.") + #expect(appSettings.seenInvites.isEmpty, "Only an invited room should register the room ID as a seen invite.") let deferred = deferFulfillment(viewModel.actionsPublisher) { $0 == .joined(.roomID("1")) } context.send(viewAction: .join) try await deferred.fulfill() } - func testAcceptInviteInteraction() async throws { - XCTAssertTrue(appSettings.seenInvites.isEmpty, "There shouldn't be any seen invites before running the tests.") + @Test + func acceptInviteInteraction() async throws { + #expect(appSettings.seenInvites.isEmpty, "There shouldn't be any seen invites before running the tests.") setupViewModel(mode: .invited) try await deferFulfillment(viewModel.context.$viewState) { $0.mode == .invited(isDM: false) }.fulfill() - XCTAssertEqual(appSettings.seenInvites, ["1"], "The invited room's ID should be registered as a seen invite.") + #expect(appSettings.seenInvites == ["1"], "The invited room's ID should be registered as a seen invite.") let deferred = deferFulfillment(viewModel.actionsPublisher) { $0 == .joined(.roomID("1")) } context.send(viewAction: .acceptInvite) try await deferred.fulfill() - XCTAssertTrue(appSettings.seenInvites.isEmpty, "The after accepting an invite the invite should be forgotten in case the user leaves.") + #expect(appSettings.seenInvites.isEmpty, "The after accepting an invite the invite should be forgotten in case the user leaves.") } - func testDeclineInviteInteraction() async throws { - XCTAssertTrue(appSettings.seenInvites.isEmpty, "There shouldn't be any seen invites before running the tests.") + @Test + func declineInviteInteraction() async throws { + #expect(appSettings.seenInvites.isEmpty, "There shouldn't be any seen invites before running the tests.") setupViewModel(mode: .invited) try await deferFulfillment(viewModel.context.$viewState) { $0.mode == .invited(isDM: false) }.fulfill() - XCTAssertEqual(appSettings.seenInvites, ["1"], "The invited room's ID should be registered as a seen invite.") + #expect(appSettings.seenInvites == ["1"], "The invited room's ID should be registered as a seen invite.") context.send(viewAction: .declineInvite) - XCTAssertEqual(viewModel.context.alertInfo?.id, .declineInvite) + #expect(viewModel.context.alertInfo?.id == .declineInvite) let deferred = deferFulfillment(viewModel.actionsPublisher) { $0 == .dismiss } context.alertInfo?.secondaryButton?.action?() try await deferred.fulfill() - XCTAssertTrue(appSettings.seenInvites.isEmpty, "The after declining an invite the invite should be forgotten in case another invite is received.") + #expect(appSettings.seenInvites.isEmpty, "The after declining an invite the invite should be forgotten in case another invite is received.") } - func testKnockedState() async throws { - XCTAssertTrue(appSettings.seenInvites.isEmpty, "There shouldn't be any seen invites before running the tests.") + @Test + func knockedState() async throws { + #expect(appSettings.seenInvites.isEmpty, "There shouldn't be any seen invites before running the tests.") setupViewModel(mode: .knocked) try await deferFulfillment(viewModel.context.$viewState) { $0.mode == .knocked }.fulfill() - XCTAssertTrue(appSettings.seenInvites.isEmpty, "Only an invited room should register the room ID as a seen invite.") + #expect(appSettings.seenInvites.isEmpty, "Only an invited room should register the room ID as a seen invite.") } - func testCancelKnock() async throws { + @Test + func cancelKnock() async throws { setupViewModel(mode: .knocked) try await deferFulfillment(viewModel.context.$viewState) { state in @@ -102,7 +108,7 @@ class JoinRoomScreenViewModelTests: XCTestCase { }.fulfill() context.send(viewAction: .cancelKnock) - XCTAssertEqual(viewModel.context.alertInfo?.id, .cancelKnock) + #expect(viewModel.context.alertInfo?.id == .cancelKnock) let deferred = deferFulfillment(viewModel.actionsPublisher) { action in action == .dismiss @@ -111,32 +117,36 @@ class JoinRoomScreenViewModelTests: XCTestCase { try await deferred.fulfill() } - func testDeclineAndBlockInviteLegacyInteraction() async throws { + @Test + func declineAndBlockInviteLegacyInteraction() async throws { setupViewModel(mode: .invited) clientProxy.underlyingIsReportRoomSupported = false - let expectation = expectation(description: "Wait for the user to be ignored") - clientProxy.ignoreUserClosure = { userID in - defer { expectation.fulfill() } - XCTAssertEqual(userID, "@test:matrix.org") - return .success(()) - } try await deferFulfillment(viewModel.context.$viewState) { $0.roomDetails != nil }.fulfill() context.send(viewAction: .declineInviteAndBlock(userID: "@test:matrix.org")) try await deferFulfillment(viewModel.context.$viewState) { $0.bindings.alertInfo != nil }.fulfill() - XCTAssertEqual(viewModel.context.alertInfo?.id, .declineInviteAndBlock) + #expect(viewModel.context.alertInfo?.id == .declineInviteAndBlock) let deferred = deferFulfillment(viewModel.actionsPublisher) { action in action == .dismiss } - context.alertInfo?.secondaryButton?.action?() - await fulfillment(of: [expectation], timeout: 10) + + await waitForConfirmation("Wait for the user to be ignored") { confirm in + clientProxy.ignoreUserClosure = { userID in + defer { confirm() } + #expect(userID == "@test:matrix.org") + return .success(()) + } + context.alertInfo?.secondaryButton?.action?() + } + try await deferred.fulfill() } - func testDeclineAndBlockInviteInteraction() async throws { + @Test + func declineAndBlockInviteInteraction() async throws { setupViewModel(mode: .invited) try await deferFulfillment(viewModel.context.$viewState) { $0.roomDetails != nil }.fulfill() let deferredAction = deferFulfillment(viewModel.actionsPublisher) { $0 == .presentDeclineAndBlock(userID: "@test:matrix.org") } @@ -144,7 +154,8 @@ class JoinRoomScreenViewModelTests: XCTestCase { try await deferredAction.fulfill() } - func testForgetRoom() async throws { + @Test + func forgetRoom() async throws { setupViewModel(mode: .banned) try await deferFulfillment(viewModel.context.$viewState) { $0.roomDetails != nil }.fulfill() diff --git a/UnitTests/Sources/LayoutTests/CollapsibleFlowLayoutTests.swift b/UnitTests/Sources/LayoutTests/CollapsibleFlowLayoutTests.swift index 3844e90bd..d03d604a4 100644 --- a/UnitTests/Sources/LayoutTests/CollapsibleFlowLayoutTests.swift +++ b/UnitTests/Sources/LayoutTests/CollapsibleFlowLayoutTests.swift @@ -8,10 +8,12 @@ @testable import ElementX import SwiftUI -import XCTest +import Testing -final class CollapsibleFlowLayoutTests: XCTestCase { - func testFlowLayoutWithExpandAndCollapse() { +@Suite +struct CollapsibleFlowLayoutTests { + @Test + func flowLayoutWithExpandAndCollapse() { let containerSize = CGSize(width: 250, height: 400) var flowLayout = CollapsibleReactionLayout(itemSpacing: 5, rowSpacing: 5, rowsBeforeCollapsible: 2) @@ -25,7 +27,7 @@ final class CollapsibleFlowLayoutTests: XCTestCase { var size = flowLayout.sizeThatFits(proposal: ProposedViewSize(containerSize), subviews: subviewsMock, cache: &a) // Collapsed target layout has 2 rows of 2 items, so just 1 spacing between items hence 205, 105 - XCTAssertEqual(size, CGSize(width: 205, height: 105)) + #expect(size == CGSize(width: 205, height: 105)) flowLayout.placeSubviews(in: CGRect(origin: .zero, size: size), proposal: ProposedViewSize(containerSize), subviews: subviewsMock, cache: &a) // 4 items are hidden in the collapsed state (put in the centre with zero size) @@ -39,7 +41,7 @@ final class CollapsibleFlowLayoutTests: XCTestCase { CGRect(x: -10000, y: -10000, width: 0, height: 0), CGRect(x: -10000, y: -10000, width: 0, height: 0) ] - XCTAssertEqual(placedViews, targetPlacements) + #expect(placedViews == targetPlacements) flowLayout.collapsed = false placedViews = [] @@ -47,7 +49,7 @@ final class CollapsibleFlowLayoutTests: XCTestCase { size = flowLayout.sizeThatFits(proposal: ProposedViewSize(containerSize), subviews: subviewsMock, cache: &a) // Expanded target layout has 4 rows and no more than 2 items per row - XCTAssertEqual(size, CGSize(width: 205, height: 215)) + #expect(size == CGSize(width: 205, height: 215)) flowLayout.placeSubviews(in: CGRect(origin: .zero, size: size), proposal: ProposedViewSize(containerSize), subviews: subviewsMock, cache: &a) @@ -61,10 +63,11 @@ final class CollapsibleFlowLayoutTests: XCTestCase { CGRect(x: 0, y: 190, width: 100, height: 50), CGRect(x: 105.0, y: 190, width: 100, height: 50) ] - XCTAssertEqual(placedViews, targetPlacements) + #expect(placedViews == targetPlacements) } - func testFlowLayoutWithExpandButtonAndAddMoreIsHidden() { + @Test + func flowLayoutWithExpandButtonAndAddMoreIsHidden() { let containerSize = CGSize(width: 250, height: 400) let flowLayout = CollapsibleReactionLayout(itemSpacing: 5, rowSpacing: 5, rowsBeforeCollapsible: 2) @@ -78,7 +81,7 @@ final class CollapsibleFlowLayoutTests: XCTestCase { var a: () = () let size = flowLayout.sizeThatFits(proposal: ProposedViewSize(containerSize), subviews: subviewsMock, cache: &a) - XCTAssertEqual(size, CGSize(width: 205, height: 105)) + #expect(size == CGSize(width: 205, height: 105)) flowLayout.placeSubviews(in: CGRect(origin: .zero, size: size), proposal: ProposedViewSize(containerSize), subviews: subviewsMock, cache: &a) let targetPlacements: [CGRect] = [ @@ -90,10 +93,11 @@ final class CollapsibleFlowLayoutTests: XCTestCase { // Expand/Collapse button is hidden CGRect(x: -10000, y: -10000, width: 0, height: 0) ] - XCTAssertEqual(placedViews, targetPlacements) + #expect(placedViews == targetPlacements) } - func testHeightIsCorrectGivenASmallerAddButton() { + @Test + func heightIsCorrectGivenASmallerAddButton() { let containerSize = CGSize(width: 250, height: 400) let flowLayout = CollapsibleReactionLayout(itemSpacing: 5, rowSpacing: 5, rowsBeforeCollapsible: 2) @@ -110,7 +114,7 @@ final class CollapsibleFlowLayoutTests: XCTestCase { var a: () = () let size = flowLayout.sizeThatFits(proposal: ProposedViewSize(containerSize), subviews: subviewsMock, cache: &a) - XCTAssertEqual(size, CGSize(width: 205, height: 105)) + #expect(size == CGSize(width: 205, height: 105)) flowLayout.placeSubviews(in: CGRect(origin: .zero, size: size), proposal: ProposedViewSize(containerSize), subviews: subviewsMock, cache: &a) let targetPlacements: [CGRect] = [ @@ -121,10 +125,11 @@ final class CollapsibleFlowLayoutTests: XCTestCase { // Expand/Collapse button is hidden CGRect(x: -10000, y: -10000, width: 0, height: 0) ] - XCTAssertEqual(placedViews, targetPlacements) + #expect(placedViews == targetPlacements) } - func testFlowLayoutEmptyState() { + @Test + func flowLayoutEmptyState() { let containerSize = CGSize(width: 250, height: 400) let flowLayout = CollapsibleReactionLayout(itemSpacing: 5, rowSpacing: 5, rowsBeforeCollapsible: 2) @@ -137,7 +142,7 @@ final class CollapsibleFlowLayoutTests: XCTestCase { var a: () = () let size = flowLayout.sizeThatFits(proposal: ProposedViewSize(containerSize), subviews: subviewsMock, cache: &a) - XCTAssertEqual(size, CGSize(width: 0, height: 0)) + #expect(size == CGSize(width: 0, height: 0)) flowLayout.placeSubviews(in: CGRect(origin: .zero, size: size), proposal: ProposedViewSize(containerSize), subviews: subviewsMock, cache: &a) let targetPlacements: [CGRect] = [ @@ -145,7 +150,7 @@ final class CollapsibleFlowLayoutTests: XCTestCase { CGRect(x: -10000, y: -10000, width: 0, height: 0), CGRect(x: -10000, y: -10000, width: 0, height: 0) ] - XCTAssertEqual(placedViews, targetPlacements) + #expect(placedViews == targetPlacements) } func createReactionLayoutSubviews(with sizes: [CGSize], diff --git a/UnitTests/Sources/MediaProvider/MediaProviderTests.swift b/UnitTests/Sources/MediaProvider/MediaProviderTests.swift index 950e12b9f..ac089fe1c 100644 --- a/UnitTests/Sources/MediaProvider/MediaProviderTests.swift +++ b/UnitTests/Sources/MediaProvider/MediaProviderTests.swift @@ -9,17 +9,19 @@ import Combine @testable import ElementX import Kingfisher -import XCTest +import SwiftUI +import Testing +@Suite @MainActor -final class MediaProviderTests: XCTestCase { - private var mediaLoader: MediaLoaderMock! - private var imageCache: MockImageCache! +struct MediaProviderTests { + private var mediaLoader: MediaLoaderMock + private var imageCache: MockImageCache private var reachabilitySubject = CurrentValueSubject(.reachable) var mediaProvider: MediaProvider! - override func setUp() { + init() { mediaLoader = MediaLoaderMock() imageCache = MockImageCache(name: "Test") @@ -28,12 +30,10 @@ final class MediaProviderTests: XCTestCase { homeserverReachabilityPublisher: reachabilitySubject.asCurrentValuePublisher()) } - func testLoadingRetriedOnReconnection() async throws { + @Test + func loadingRetriedOnReconnection() async throws { let testImage = try loadTestImage() - guard let pngData = testImage.pngData() else { - XCTFail("Test image should contain valid .png data") - return - } + let pngData = try #require(testImage.pngData(), "Test image should contain valid .png data") let loadTask = try mediaProvider.loadImageRetryingOnReconnection(MediaSourceProxy(url: .mockMXCImage, mimeType: "image/jpeg")) @@ -51,11 +51,12 @@ final class MediaProviderTests: XCTestCase { let result = try? await loadTask.value - XCTAssertNotNil(result) - XCTAssertEqual(mediaLoader.loadMediaContentForSourceCallsCount, 2) + #expect(result != nil) + #expect(mediaLoader.loadMediaContentForSourceCallsCount == 2) } - func testLoadingRetriedOnReconnectionCancelsAfterSecondFailure() async throws { + @Test + func loadingRetriedOnReconnectionCancelsAfterSecondFailure() async throws { let loadTask = try mediaProvider.loadImageRetryingOnReconnection(MediaSourceProxy(url: .mockMXCImage, mimeType: "image/jpeg")) reachabilitySubject.send(.reachable) @@ -64,15 +65,17 @@ final class MediaProviderTests: XCTestCase { let result = try? await loadTask.value - XCTAssertNil(result) + #expect(result == nil) } - func test_whenImageFromSourceWithSourceNil_nilReturned() { + @Test + func whenImageFromSourceWithSourceNil_nilReturned() { let image = mediaProvider.imageFromSource(nil, size: Avatars.Size.room(on: .timeline).scaledSize) - XCTAssertNil(image) + #expect(image == nil) } - func test_whenImageFromSourceWithSourceNotNilAndImageCacheContainsImage_ImageIsReturned() throws { + @Test + func whenImageFromSourceWithSourceNotNilAndImageCacheContainsImage_ImageIsReturned() throws { let avatarSize = Avatars.Size.room(on: .timeline) let url = URL.mockMXCImage let key = "\(url.absoluteString){\(avatarSize.scaledValue),\(avatarSize.scaledValue)}" @@ -80,16 +83,18 @@ final class MediaProviderTests: XCTestCase { imageCache.retrievedImagesInMemory[key] = imageForKey let image = try mediaProvider.imageFromSource(MediaSourceProxy(url: url, mimeType: "image/jpeg"), size: avatarSize.scaledSize) - XCTAssertEqual(image, imageForKey) + #expect(image == imageForKey) } - func test_whenImageFromSourceWithSourceNotNilAndImageNotCached_nilReturned() throws { + @Test + func whenImageFromSourceWithSourceNotNilAndImageNotCached_nilReturned() throws { let image = try mediaProvider.imageFromSource(MediaSourceProxy(url: .mockMXCImage, mimeType: "image/jpeg"), size: Avatars.Size.room(on: .timeline).scaledSize) - XCTAssertNil(image) + #expect(image == nil) } - func test_whenLoadImageFromSourceAndImageCacheContainsImage_successIsReturned() async throws { + @Test + func whenLoadImageFromSourceAndImageCacheContainsImage_successIsReturned() async throws { let avatarSize = Avatars.Size.room(on: .timeline) let url = URL.mockMXCImage let key = "\(url.absoluteString){\(avatarSize.scaledValue),\(avatarSize.scaledValue)}" @@ -97,10 +102,11 @@ final class MediaProviderTests: XCTestCase { imageCache.retrievedImagesInMemory[key] = imageForKey let result = try await mediaProvider.loadImageFromSource(MediaSourceProxy(url: url, mimeType: "image/jpeg"), size: avatarSize.scaledSize) - XCTAssertEqual(Result.success(imageForKey), result) + #expect(Result.success(imageForKey) == result) } - func test_whenLoadImageFromSourceAndImageNotCachedAndRetrieveImageSucceeds_successIsReturned() async throws { + @Test + func whenLoadImageFromSourceAndImageNotCachedAndRetrieveImageSucceeds_successIsReturned() async throws { let avatarSize = Avatars.Size.room(on: .timeline) let url = URL.mockMXCImage let key = "\(url.absoluteString){\(avatarSize.scaledValue),\(avatarSize.scaledValue)}" @@ -108,10 +114,11 @@ final class MediaProviderTests: XCTestCase { imageCache.retrievedImages[key] = imageForKey let result = try await mediaProvider.loadImageFromSource(MediaSourceProxy(url: url, mimeType: "image/jpeg"), size: avatarSize.scaledSize) - XCTAssertEqual(Result.success(imageForKey), result) + #expect(Result.success(imageForKey) == result) } - func test_whenLoadImageFromSourceAndImageNotCachedAndRetrieveImageFails_imageThumbnailIsLoaded() async throws { + @Test + func whenLoadImageFromSourceAndImageNotCachedAndRetrieveImageFails_imageThumbnailIsLoaded() async throws { let avatarSize = Avatars.Size.room(on: .timeline) let expectedImage = try loadTestImage() @@ -121,13 +128,14 @@ final class MediaProviderTests: XCTestCase { size: avatarSize.scaledSize) switch result { case .success(let image): - XCTAssertEqual(image.pngData(), expectedImage.pngData()) + #expect(image.pngData() == expectedImage.pngData()) case .failure: - XCTFail("Should be success") + Issue.record("Should be success") } } - func test_whenLoadImageFromSourceAndImageNotCachedAndRetrieveImageFails_imageIsStored() async throws { + @Test + func whenLoadImageFromSourceAndImageNotCachedAndRetrieveImageFails_imageIsStored() async throws { let avatarSize = Avatars.Size.room(on: .timeline) let url = URL.mockMXCImage let key = "\(url.absoluteString){\(avatarSize.scaledValue),\(avatarSize.scaledValue)}" @@ -137,11 +145,12 @@ final class MediaProviderTests: XCTestCase { _ = try await mediaProvider.loadImageFromSource(MediaSourceProxy(url: url, mimeType: "image/jpeg"), size: avatarSize.scaledSize) - let storedImage = try XCTUnwrap(imageCache.storedImages[key]) - XCTAssertEqual(expectedImage.pngData(), storedImage.pngData()) + let storedImage = try #require(imageCache.storedImages[key]) + #expect(expectedImage.pngData() == storedImage.pngData()) } - func test_whenLoadImageFromSourceAndImageNotCachedAndRetrieveImageFailsAndNoAvatarSize_imageContentIsLoaded() async throws { + @Test + func whenLoadImageFromSourceAndImageNotCachedAndRetrieveImageFailsAndNoAvatarSize_imageContentIsLoaded() async throws { let expectedImage = try loadTestImage() mediaLoader.loadMediaContentForSourceReturnValue = expectedImage.pngData() @@ -150,53 +159,56 @@ final class MediaProviderTests: XCTestCase { size: nil) switch result { case .success(let image): - XCTAssertEqual(image.pngData(), expectedImage.pngData()) + #expect(image.pngData() == expectedImage.pngData()) case .failure: - XCTFail("Should be success") + Issue.record("Should be success") } } - func test_whenLoadImageFromSourceAndImageNotCachedAndRetrieveImageFailsAndLoadImageThumbnailFails_errorIsThrown() async throws { + @Test + func whenLoadImageFromSourceAndImageNotCachedAndRetrieveImageFailsAndLoadImageThumbnailFails_errorIsThrown() async throws { mediaLoader.loadMediaThumbnailForSourceWidthHeightThrowableError = MediaProviderTestsError.error let result = try await mediaProvider.loadImageFromSource(MediaSourceProxy(url: .mockMXCImage, mimeType: "image/jpeg"), size: Avatars.Size.room(on: .timeline).scaledSize) switch result { case .success: - XCTFail("Should fail") + Issue.record("Should fail") case .failure(let error): - XCTAssertEqual(error, MediaProviderError.failedRetrievingImage) + #expect(error == MediaProviderError.failedRetrievingImage) } } - func test_whenLoadImageFromSourceAndImageNotCachedAndRetrieveImageFailsAndNoAvatarSizeAndLoadImageContentFails_errorIsThrown() async throws { + @Test + func whenLoadImageFromSourceAndImageNotCachedAndRetrieveImageFailsAndNoAvatarSizeAndLoadImageContentFails_errorIsThrown() async throws { mediaLoader.loadMediaContentForSourceThrowableError = MediaProviderTestsError.error let result = try await mediaProvider.loadImageFromSource(MediaSourceProxy(url: .mockMXCImage, mimeType: "image/jpeg"), size: nil) switch result { case .success: - XCTFail("Should fail") + Issue.record("Should fail") case .failure(let error): - XCTAssertEqual(error, MediaProviderError.failedRetrievingImage) + #expect(error == MediaProviderError.failedRetrievingImage) } } - func test_whenLoadImageFromSourceAndImageNotCachedAndRetrieveImageFailsAndImageThumbnailIsLoadedWithCorruptedData_errorIsThrown() async throws { + @Test + func whenLoadImageFromSourceAndImageNotCachedAndRetrieveImageFailsAndImageThumbnailIsLoadedWithCorruptedData_errorIsThrown() async throws { mediaLoader.loadMediaThumbnailForSourceWidthHeightReturnValue = Data() let result = try await mediaProvider.loadImageFromSource(MediaSourceProxy(url: .mockMXCImage, mimeType: "image/jpeg"), size: Avatars.Size.room(on: .timeline).scaledSize) switch result { case .success: - XCTFail("Should fail") + Issue.record("Should fail") case .failure(let error): - XCTAssertEqual(error, MediaProviderError.invalidImageData) + #expect(error == MediaProviderError.invalidImageData) } } private func loadTestImage() throws -> UIImage { - guard let path = Bundle(for: Self.self).path(forResource: "test_image", ofType: "png"), + guard let path = Bundle(for: UnitTestsAppCoordinator.self).path(forResource: "test_image", ofType: "png"), let image = UIImage(contentsOfFile: path) else { throw MediaProviderTestsError.error } diff --git a/UnitTests/Sources/MediaUploadPreviewScreenViewModelTests.swift b/UnitTests/Sources/MediaUploadPreviewScreenViewModelTests.swift index 0d7177262..0dd0da22d 100644 --- a/UnitTests/Sources/MediaUploadPreviewScreenViewModelTests.swift +++ b/UnitTests/Sources/MediaUploadPreviewScreenViewModelTests.swift @@ -7,10 +7,12 @@ // @testable import ElementX -import XCTest +import Foundation +import Testing +@Suite @MainActor -class MediaUploadPreviewScreenViewModelTests: XCTestCase { +final class MediaUploadPreviewScreenViewModelTests { var timelineProxy: TimelineProxyMock! var clientProxy: ClientProxyMock! var userIndicatorController: UserIndicatorControllerMock! @@ -25,7 +27,7 @@ class MediaUploadPreviewScreenViewModelTests: XCTestCase { case unknown } - override func setUp() { + init() { AppSettings.resetAllSettings() let appSettings = AppSettings() appSettings.optimizeMediaUploads = false @@ -36,190 +38,205 @@ class MediaUploadPreviewScreenViewModelTests: XCTestCase { AppSettings.resetAllSettings() } - func testImageUploadWithoutCaption() async throws { + @Test + func imageUploadWithoutCaption() async throws { setUpViewModel(urls: [imageURL], expectedCaption: nil) context.caption = .init("") try await send() } - func testImageUploadWithBlankCaption() async throws { + @Test + func imageUploadWithBlankCaption() async throws { setUpViewModel(urls: [imageURL], expectedCaption: nil) context.caption = .init(" ") try await send() } - func testImageUploadWithCaption() async throws { + @Test + func imageUploadWithCaption() async throws { let caption = "This is a really great image!" setUpViewModel(urls: [imageURL], expectedCaption: caption) context.caption = .init(string: caption) try await send() } - func testVideoUploadWithoutCaption() async throws { + @Test + func videoUploadWithoutCaption() async throws { setUpViewModel(urls: [videoURL], expectedCaption: nil) context.caption = .init("") try await send() } - func testVideoUploadWithCaption() async throws { + @Test + func videoUploadWithCaption() async throws { let caption = "Check out this video!" setUpViewModel(urls: [videoURL], expectedCaption: caption) context.caption = .init(string: caption) try await send() } - func testAudioUploadWithoutCaption() async throws { + @Test + func audioUploadWithoutCaption() async throws { setUpViewModel(urls: [audioURL], expectedCaption: nil) context.caption = .init("") try await send() } - func testAudioUploadWithCaption() async throws { + @Test + func audioUploadWithCaption() async throws { let caption = "Listen to this!" setUpViewModel(urls: [audioURL], expectedCaption: caption) context.caption = .init(string: caption) try await send() } - func testFileUploadWithoutCaption() async throws { + @Test + func fileUploadWithoutCaption() async throws { setUpViewModel(urls: [fileURL], expectedCaption: nil) context.caption = .init("") try await send() } - func testFileUploadWithCaption() async throws { + @Test + func fileUploadWithCaption() async throws { let caption = "Please will you check my article." setUpViewModel(urls: [fileURL], expectedCaption: caption) context.caption = .init(string: caption) try await send() } - func testProcessingFailure() async throws { + @Test + func processingFailure() async throws { // Given an upload screen for a non-existent file. setUpViewModel(urls: [badImageURL], expectedCaption: nil) - XCTAssertFalse(context.viewState.shouldDisableInteraction) - XCTAssertEqual(userIndicatorController.submitIndicatorDelayCallsCount, 0) + #expect(!context.viewState.shouldDisableInteraction) + #expect(userIndicatorController.submitIndicatorDelayCallsCount == 0) // When attempting to send the file - let deferredFailure = deferFailure(viewModel.actions, timeout: 1, message: "The screen should remain visible.") { $0 == .dismiss } + let deferredFailure = deferFailure(viewModel.actions, timeout: .seconds(1), message: "The screen should remain visible.") { $0 == .dismiss } context.send(viewAction: .send) - XCTAssertTrue(context.viewState.shouldDisableInteraction, "The interaction should be disabled while sending.") - XCTAssertEqual(userIndicatorController.submitIndicatorDelayCallsCount, 1) // Loading indicator + #expect(context.viewState.shouldDisableInteraction, "The interaction should be disabled while sending.") + #expect(userIndicatorController.submitIndicatorDelayCallsCount == 1) // Loading indicator // Then the failure should occur preventing the screen from being dismissed. try await deferredFailure.fulfill() - XCTAssertFalse(context.viewState.shouldDisableInteraction) - XCTAssertEqual(userIndicatorController.submitIndicatorDelayCallsCount, 2, "An error indicator should be shown.") + #expect(!context.viewState.shouldDisableInteraction) + #expect(userIndicatorController.submitIndicatorDelayCallsCount == 2, "An error indicator should be shown.") } - func testUploadWithUnknownMaxUploadSize() async throws { + @Test + func uploadWithUnknownMaxUploadSize() async throws { // Given an upload screen that is unable to fetch the max upload size. setUpViewModel(urls: [imageURL], expectedCaption: nil, maxUploadSizeResult: .failure(.sdkError(ClientProxyMockError.generic))) - XCTAssertFalse(context.viewState.shouldDisableInteraction) - XCTAssertNil(context.alertInfo) + #expect(!context.viewState.shouldDisableInteraction) + #expect(context.alertInfo == nil) // When attempting to send the media. let deferredAlert = deferFulfillment(context.observe(\.viewState.bindings.alertInfo)) { $0 != nil } - let deferredFailure = deferFailure(viewModel.actions, timeout: 1, message: "The screen should remain visible.") { $0 == .dismiss } + let deferredFailure = deferFailure(viewModel.actions, timeout: .seconds(1), message: "The screen should remain visible.") { $0 == .dismiss } context.send(viewAction: .send) - XCTAssertTrue(context.viewState.shouldDisableInteraction, "The interaction should be disabled while sending.") + #expect(context.viewState.shouldDisableInteraction, "The interaction should be disabled while sending.") // Then alert should be shown to tell the user it failed. try await deferredAlert.fulfill() try await deferredFailure.fulfill() - XCTAssertFalse(context.viewState.shouldDisableInteraction) - XCTAssertEqual(context.alertInfo?.id, .maxUploadSizeUnknown) + #expect(!context.viewState.shouldDisableInteraction) + #expect(context.alertInfo?.id == .maxUploadSizeUnknown) // When trying with the max upload size now available. let deferredDismiss = deferFulfillment(viewModel.actions) { $0 == .dismiss } clientProxy.underlyingMaxMediaUploadSize = .success(100 * 1024 * 1024) context.alertInfo?.primaryButton.action?() - XCTAssertTrue(context.viewState.shouldDisableInteraction, "The interaction should be disabled while retrying.") + #expect(context.viewState.shouldDisableInteraction, "The interaction should be disabled while retrying.") // Then the file should upload successfully. try await deferredDismiss.fulfill() } - func testUploadExceedingMaxUploadSize() async throws { + @Test + func uploadExceedingMaxUploadSize() async throws { // Given an upload screen with a really small max upload size. setUpViewModel(urls: [imageURL], expectedCaption: nil, maxUploadSizeResult: .success(100)) - XCTAssertFalse(context.viewState.shouldDisableInteraction) - XCTAssertNil(context.alertInfo) + #expect(!context.viewState.shouldDisableInteraction) + #expect(context.alertInfo == nil) // When attempting to send an image that is larger the limit. let deferredAlert = deferFulfillment(context.observe(\.viewState.bindings.alertInfo)) { $0 != nil } - let deferredFailure = deferFailure(viewModel.actions, timeout: 1, message: "The screen should remain visible.") { $0 == .dismiss } + let deferredFailure = deferFailure(viewModel.actions, timeout: .seconds(1), message: "The screen should remain visible.") { $0 == .dismiss } context.send(viewAction: .send) - XCTAssertTrue(context.viewState.shouldDisableInteraction, "The interaction should be disabled while sending.") + #expect(context.viewState.shouldDisableInteraction, "The interaction should be disabled while sending.") // Then an alert should be shown to inform the user of the max upload size. try await deferredAlert.fulfill() try await deferredFailure.fulfill() - XCTAssertFalse(context.viewState.shouldDisableInteraction) - XCTAssertEqual(context.alertInfo?.id, .maxUploadSizeExceeded(limit: 100)) + #expect(!context.viewState.shouldDisableInteraction) + #expect(context.alertInfo?.id == .maxUploadSizeExceeded(limit: 100)) } - func testMultipleFiles() async throws { + @Test + func multipleFiles() async throws { // Given an upload screen with multiple media files. setUpViewModel(urls: [fileURL, imageURL, fileURL], expectedCaption: nil) - XCTAssertFalse(context.viewState.shouldDisableInteraction) - XCTAssertEqual(userIndicatorController.submitIndicatorDelayCallsCount, 0) + #expect(!context.viewState.shouldDisableInteraction) + #expect(userIndicatorController.submitIndicatorDelayCallsCount == 0) // When attempting to send the files. let deferredDismiss = deferFulfillment(viewModel.actions) { $0 == .dismiss } context.send(viewAction: .send) - XCTAssertTrue(context.viewState.shouldDisableInteraction, "The interaction should be disabled while sending.") - XCTAssertEqual(userIndicatorController.submitIndicatorDelayCallsCount, 1) // Loading indicator + #expect(context.viewState.shouldDisableInteraction, "The interaction should be disabled while sending.") + #expect(userIndicatorController.submitIndicatorDelayCallsCount == 1) // Loading indicator // Then the screen should be dismissed once all of the files have been sent. try await deferredDismiss.fulfill() - XCTAssertEqual(timelineProxy.sendImageUrlThumbnailURLImageInfoCaptionRequestHandleCallsCount, 1) - XCTAssertEqual(timelineProxy.sendFileUrlFileInfoCaptionRequestHandleCallsCount, 2) - XCTAssertEqual(userIndicatorController.submitIndicatorDelayCallsCount, 1, "Only a loading indicator should be shown.") + #expect(timelineProxy.sendImageUrlThumbnailURLImageInfoCaptionRequestHandleCallsCount == 1) + #expect(timelineProxy.sendFileUrlFileInfoCaptionRequestHandleCallsCount == 2) + #expect(userIndicatorController.submitIndicatorDelayCallsCount == 1, "Only a loading indicator should be shown.") } - func testMultipleFilesWithProcessingFailure() async throws { + @Test + func multipleFilesWithProcessingFailure() async throws { // Given an upload screen for a non-existent file. setUpViewModel(urls: [imageURL, fileURL, badImageURL], expectedCaption: nil) - XCTAssertFalse(context.viewState.shouldDisableInteraction) - XCTAssertEqual(userIndicatorController.submitIndicatorDelayCallsCount, 0) + #expect(!context.viewState.shouldDisableInteraction) + #expect(userIndicatorController.submitIndicatorDelayCallsCount == 0) // When attempting to send the file - let deferredFailure = deferFailure(viewModel.actions, timeout: 1, message: "The screen should remain visible.") { $0 == .dismiss } + let deferredFailure = deferFailure(viewModel.actions, timeout: .seconds(1), message: "The screen should remain visible.") { $0 == .dismiss } context.send(viewAction: .send) - XCTAssertTrue(context.viewState.shouldDisableInteraction, "The interaction should be disabled while sending.") - XCTAssertEqual(userIndicatorController.submitIndicatorDelayCallsCount, 1) // Loading indicator + #expect(context.viewState.shouldDisableInteraction, "The interaction should be disabled while sending.") + #expect(userIndicatorController.submitIndicatorDelayCallsCount == 1) // Loading indicator // Then the failure should occur preventing the screen from being dismissed. try await deferredFailure.fulfill() - XCTAssertFalse(context.viewState.shouldDisableInteraction) - XCTAssertEqual(userIndicatorController.submitIndicatorDelayCallsCount, 2, "An error indicator should be shown.") + #expect(!context.viewState.shouldDisableInteraction) + #expect(userIndicatorController.submitIndicatorDelayCallsCount == 2, "An error indicator should be shown.") } - func testMultipleFilesWithSendFailure() async throws { + @Test + func multipleFilesWithSendFailure() async throws { // Given an upload screen with multiple media files where one of the files will fail to send. setUpViewModel(urls: [fileURL, imageURL, imageURL, fileURL], expectedCaption: nil, simulateImageSendFailures: true) - XCTAssertFalse(context.viewState.shouldDisableInteraction) - XCTAssertEqual(userIndicatorController.submitIndicatorDelayCallsCount, 0) + #expect(!context.viewState.shouldDisableInteraction) + #expect(userIndicatorController.submitIndicatorDelayCallsCount == 0) // When attempting to send the files. let deferredDismiss = deferFulfillment(viewModel.actions) { $0 == .dismiss } context.send(viewAction: .send) - XCTAssertTrue(context.viewState.shouldDisableInteraction, "The interaction should be disabled while sending.") - XCTAssertEqual(userIndicatorController.submitIndicatorDelayCallsCount, 1) // Loading indicator + #expect(context.viewState.shouldDisableInteraction, "The interaction should be disabled while sending.") + #expect(userIndicatorController.submitIndicatorDelayCallsCount == 1) // Loading indicator // Then the screen should be dismissed so the user can see which files made it into the timeline. try await deferredDismiss.fulfill() - XCTAssertEqual(timelineProxy.sendImageUrlThumbnailURLImageInfoCaptionRequestHandleCallsCount, 2) - XCTAssertEqual(timelineProxy.sendFileUrlFileInfoCaptionRequestHandleCallsCount, 2) - XCTAssertEqual(userIndicatorController.submitIndicatorDelayCallsCount, 3, "Error indicators for each failure should be shown.") + #expect(timelineProxy.sendImageUrlThumbnailURLImageInfoCaptionRequestHandleCallsCount == 2) + #expect(timelineProxy.sendFileUrlFileInfoCaptionRequestHandleCallsCount == 2) + #expect(userIndicatorController.submitIndicatorDelayCallsCount == 3, "Error indicators for each failure should be shown.") } // MARK: - Helpers @@ -244,7 +261,7 @@ class MediaUploadPreviewScreenViewModelTests: XCTestCase { private func assertResourceURL(filename: String) -> URL { guard let url = Bundle(for: Self.self).url(forResource: filename, withExtension: nil) else { - XCTFail("Failed retrieving test asset") + Issue.record("Failed retrieving test asset") return .picturesDirectory } return url @@ -288,19 +305,19 @@ class MediaUploadPreviewScreenViewModelTests: XCTestCase { private func verifyCaption(_ caption: String?, expectedCaption: String?) -> Result { guard caption == expectedCaption else { - XCTFail("The sent caption '\(caption ?? "nil")' does not match the expected value '\(expectedCaption ?? "nil")'").self + Issue.record("The sent caption '\(caption ?? "nil")' does not match the expected value '\(expectedCaption ?? "nil")'") return .failure(.sdkError(TestError.unexpectedParameter)) } return .success(()) } private func send() async throws { - XCTAssertFalse(context.viewState.shouldDisableInteraction, "Attempting to send when interaction is disabled.") + #expect(!context.viewState.shouldDisableInteraction, "Attempting to send when interaction is disabled.") let deferred = deferFulfillment(viewModel.actions) { $0 == .dismiss } context.send(viewAction: .send) - XCTAssertTrue(context.viewState.shouldDisableInteraction, "The interaction should be disabled while sending.") + #expect(context.viewState.shouldDisableInteraction, "The interaction should be disabled while sending.") try await deferred.fulfill() } diff --git a/UnitTests/Sources/MediaUploadingPreprocessorTests.swift b/UnitTests/Sources/MediaUploadingPreprocessorTests.swift index 5cc9c8da6..cd4ab35df 100644 --- a/UnitTests/Sources/MediaUploadingPreprocessorTests.swift +++ b/UnitTests/Sources/MediaUploadingPreprocessorTests.swift @@ -7,15 +7,17 @@ // @testable import ElementX +import SwiftUI +import Testing import UniformTypeIdentifiers -import XCTest -final class MediaUploadingPreprocessorTests: XCTestCase { +@Suite +final class MediaUploadingPreprocessorTests { let maxUploadSize: UInt = 100 * 1024 * 1024 var appSettings: AppSettings! var mediaUploadingPreprocessor: MediaUploadingPreprocessor! - override func setUp() { + init() { AppSettings.resetAllSettings() appSettings = AppSettings() appSettings.optimizeMediaUploads = false @@ -23,450 +25,426 @@ final class MediaUploadingPreprocessorTests: XCTestCase { mediaUploadingPreprocessor = MediaUploadingPreprocessor(appSettings: appSettings) } - override func tearDown() { + deinit { AppSettings.resetAllSettings() } - func testAudioFileProcessing() async { - guard let url = Bundle(for: Self.self).url(forResource: "test_audio.mp3", withExtension: nil) else { - XCTFail("Failed retrieving test asset") - return - } + @Test + func audioFileProcessing() async throws { + let url = try #require(Bundle(for: Self.self).url(forResource: "test_audio.mp3", withExtension: nil), "Failed retrieving test asset") guard case let .success(result) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize), case let .audio(audioURL, audioInfo) = result else { - XCTFail("Failed processing asset") + Issue.record("Failed processing asset") return } // Check that the file name is preserved - XCTAssertEqual(audioURL.lastPathComponent, "test_audio.mp3") + #expect(audioURL.lastPathComponent == "test_audio.mp3") - XCTAssertEqual(audioInfo.mimetype, "audio/mpeg") - XCTAssertEqual(audioInfo.duration ?? 0, 27, accuracy: 100) - XCTAssertEqual(audioInfo.size ?? 0, 194_811, accuracy: 100) + #expect(audioInfo.mimetype == "audio/mpeg") + #expect(isEqual(audioInfo.duration ?? 0, 27, within: 100)) + #expect(isEqual(audioInfo.size ?? 0, 194_811, within: 100)) } - func testLandscapeMovVideoProcessing() async { - // Allow an increased execution time as we encode the video twice now. - executionTimeAllowance = 180 - - guard let url = Bundle(for: Self.self).url(forResource: "landscape_test_video.mov", withExtension: nil) else { - XCTFail("Failed retrieving test asset") - return - } + @Test + func landscapeMovVideoProcessing() async throws { + let url = try #require(Bundle(for: Self.self).url(forResource: "landscape_test_video.mov", withExtension: nil), "Failed retrieving test asset") guard case let .success(result) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize), case let .video(videoURL, thumbnailURL, videoInfo) = result else { - XCTFail("Failed processing asset") + Issue.record("Failed processing asset") return } // Check that the file name is preserved - XCTAssertEqual(videoURL.lastPathComponent, "landscape_test_video.mp4") - XCTAssertEqual(videoURL.pathExtension, "mp4", "The file extension should match the container we use.") + #expect(videoURL.lastPathComponent == "landscape_test_video.mp4") + #expect(videoURL.pathExtension == "mp4", "The file extension should match the container we use.") // Check that the thumbnail is generated correctly - guard let thumbnailData = try? Data(contentsOf: thumbnailURL), - let thumbnail = UIImage(data: thumbnailData) else { - XCTFail("Invalid thumbnail") - return - } + let thumbnailData = try Data(contentsOf: thumbnailURL) + let thumbnail = try #require(UIImage(data: thumbnailData), "Invalid thumbnail") - XCTAssert(thumbnail.size.width <= MediaUploadingPreprocessor.Constants.maximumThumbnailSize.width) - XCTAssert(thumbnail.size.height <= MediaUploadingPreprocessor.Constants.maximumThumbnailSize.height) + #expect(thumbnail.size.width <= MediaUploadingPreprocessor.Constants.maximumThumbnailSize.width) + #expect(thumbnail.size.height <= MediaUploadingPreprocessor.Constants.maximumThumbnailSize.height) // Check resulting video info - XCTAssertEqual(videoInfo.mimetype, "video/mp4") - XCTAssertEqual(videoInfo.blurhash, "K9F$LJZ9,+8yA9-:yT,@%1") - XCTAssertEqual(videoInfo.size ?? 0, 4_016_620, accuracy: 100) - XCTAssertEqual(videoInfo.width, 1280) - XCTAssertEqual(videoInfo.height, 720) - XCTAssertEqual(videoInfo.duration ?? 0, 30, accuracy: 100) + #expect(videoInfo.mimetype == "video/mp4") + #expect(videoInfo.blurhash == "K9F$LJZ9,+8yA9-:yT,@%1") + #expect(isEqual(videoInfo.size ?? 0, 4_016_620, within: 100)) + #expect(videoInfo.width == 1280) + #expect(videoInfo.height == 720) + #expect(isEqual(videoInfo.duration ?? 0, 30, within: 100)) - XCTAssertNotNil(videoInfo.thumbnailInfo) - XCTAssertEqual(videoInfo.thumbnailInfo?.mimetype, "image/jpeg") - XCTAssertEqual(videoInfo.thumbnailInfo?.size ?? 0, 183_093, accuracy: 100) - XCTAssertEqual(videoInfo.thumbnailInfo?.width, 800) - XCTAssertEqual(videoInfo.thumbnailInfo?.height, 450) + #expect(videoInfo.thumbnailInfo != nil) + #expect(videoInfo.thumbnailInfo?.mimetype == "image/jpeg") + #expect(isEqual(videoInfo.thumbnailInfo?.size ?? 0, 183_093, within: 100)) + #expect(videoInfo.thumbnailInfo?.width == 800) + #expect(videoInfo.thumbnailInfo?.height == 450) // Repeat with optimised media setting appSettings.optimizeMediaUploads = true guard case let .success(optimizedResult) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize), case let .video(optimizedVideoURL, _, optimizedVideoInfo) = optimizedResult else { - XCTFail("Failed processing asset") + Issue.record("Failed processing asset") return } - XCTAssertEqual(optimizedVideoURL.pathExtension, "mp4", "The file extension should match the container we use.") + #expect(optimizedVideoURL.pathExtension == "mp4", "The file extension should match the container we use.") // Check optimised video info - XCTAssertEqual(optimizedVideoInfo.mimetype, "video/mp4") - XCTAssertEqual(optimizedVideoInfo.blurhash, "K9F$LJZ9,+8yA9-:yT,@%1") - XCTAssertEqual(optimizedVideoInfo.size ?? 0, 4_016_620, accuracy: 100) // Note: The video is already 720p so it doesn't change size. - XCTAssertEqual(optimizedVideoInfo.width, 1280) - XCTAssertEqual(optimizedVideoInfo.height, 720) - XCTAssertEqual(optimizedVideoInfo.duration ?? 0, 30, accuracy: 100) + #expect(optimizedVideoInfo.mimetype == "video/mp4") + #expect(optimizedVideoInfo.blurhash == "K9F$LJZ9,+8yA9-:yT,@%1") + #expect(isEqual(optimizedVideoInfo.size ?? 0, 4_016_620, within: 100)) // Note: The video is already 720p so it doesn't change size. + #expect(optimizedVideoInfo.width == 1280) + #expect(optimizedVideoInfo.height == 720) + #expect(isEqual(optimizedVideoInfo.duration ?? 0, 30, within: 100)) } - func testPortraitMp4VideoProcessing() async { - // Allow an increased execution time as we encode the video twice now. - executionTimeAllowance = 180 - - guard let url = Bundle(for: Self.self).url(forResource: "portrait_test_video.mp4", withExtension: nil) else { - XCTFail("Failed retrieving test asset") - return - } + @Test + func portraitMp4VideoProcessing() async throws { + let url = try #require(Bundle(for: Self.self).url(forResource: "portrait_test_video.mp4", withExtension: nil), "Failed retrieving test asset") guard case let .success(result) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize), case let .video(videoURL, thumbnailURL, videoInfo) = result else { - XCTFail("Failed processing asset") + Issue.record("Failed processing asset") return } // Check that the file name is preserved - XCTAssertEqual(videoURL.lastPathComponent, "portrait_test_video.mp4") - XCTAssertEqual(videoURL.pathExtension, "mp4", "The file extension should match the container we use.") + #expect(videoURL.lastPathComponent == "portrait_test_video.mp4") + #expect(videoURL.pathExtension == "mp4", "The file extension should match the container we use.") // Check that the thumbnail is generated correctly - guard let thumbnailData = try? Data(contentsOf: thumbnailURL), - let thumbnail = UIImage(data: thumbnailData) else { - XCTFail("Invalid thumbnail") - return - } + let thumbnailData = try Data(contentsOf: thumbnailURL) + let thumbnail = try #require(UIImage(data: thumbnailData), "Invalid thumbnail") - XCTAssert(thumbnail.size.width <= MediaUploadingPreprocessor.Constants.maximumThumbnailSize.width) - XCTAssert(thumbnail.size.height <= MediaUploadingPreprocessor.Constants.maximumThumbnailSize.height) + #expect(thumbnail.size.width <= MediaUploadingPreprocessor.Constants.maximumThumbnailSize.width) + #expect(thumbnail.size.height <= MediaUploadingPreprocessor.Constants.maximumThumbnailSize.height) // Check resulting video info - XCTAssertEqual(videoInfo.mimetype, "video/mp4") - XCTAssertEqual(videoInfo.blurhash, "KSB{R8O]MuwQS4oJvcaIt8") - XCTAssertEqual(videoInfo.size ?? 0, 5_824_946, accuracy: 100) - XCTAssertEqual(videoInfo.width, 1080) - XCTAssertEqual(videoInfo.height, 1920) - XCTAssertEqual(videoInfo.duration ?? 0, 21, accuracy: 100) + #expect(videoInfo.mimetype == "video/mp4") + #expect(videoInfo.blurhash == "KSB{R8O]MuwQS4oJvcaIt8") + #expect(isEqual(videoInfo.size ?? 0, 5_824_946, within: 100)) + #expect(videoInfo.width == 1080) + #expect(videoInfo.height == 1920) + #expect(isEqual(videoInfo.duration ?? 0, 21, within: 100)) - XCTAssertNotNil(videoInfo.thumbnailInfo) - XCTAssertEqual(videoInfo.thumbnailInfo?.mimetype, "image/jpeg") - XCTAssertEqual(videoInfo.thumbnailInfo?.size ?? 0, 40976, accuracy: 100) - XCTAssertEqual(videoInfo.thumbnailInfo?.width, 337) - XCTAssertEqual(videoInfo.thumbnailInfo?.height, 600) + #expect(videoInfo.thumbnailInfo != nil) + #expect(videoInfo.thumbnailInfo?.mimetype == "image/jpeg") + #expect(isEqual(videoInfo.thumbnailInfo?.size ?? 0, 40976, within: 100)) + #expect(videoInfo.thumbnailInfo?.width == 337) + #expect(videoInfo.thumbnailInfo?.height == 600) // Repeat with optimised media setting appSettings.optimizeMediaUploads = true guard case let .success(optimizedResult) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize), case let .video(optimizedVideoURL, _, optimizedVideoInfo) = optimizedResult else { - XCTFail("Failed processing asset") + Issue.record("Failed processing asset") return } - XCTAssertEqual(optimizedVideoURL.pathExtension, "mp4", "The file extension should match the container we use.") + #expect(optimizedVideoURL.pathExtension == "mp4", "The file extension should match the container we use.") // Check optimised video info - XCTAssertEqual(optimizedVideoInfo.mimetype, "video/mp4") - XCTAssertEqual(optimizedVideoInfo.blurhash, "KSC5.vO]MuwQS4oJvcaIt8") - XCTAssertEqual(optimizedVideoInfo.size ?? 0, 12_169_117, accuracy: 100) // Note: This is slightly stupid because it is larger now 🤦‍♂️ - XCTAssertEqual(optimizedVideoInfo.width, 720) - XCTAssertEqual(optimizedVideoInfo.height, 1280) - XCTAssertEqual(optimizedVideoInfo.duration ?? 0, 30, accuracy: 100) + #expect(optimizedVideoInfo.mimetype == "video/mp4") + #expect(optimizedVideoInfo.blurhash == "KSC5.vO]MuwQS4oJvcaIt8") + #expect(isEqual(optimizedVideoInfo.size ?? 0, 12_169_117, within: 100)) // Note: This is slightly stupid because it is larger now 🤦‍♂️ + #expect(optimizedVideoInfo.width == 720) + #expect(optimizedVideoInfo.height == 1280) + #expect(isEqual(optimizedVideoInfo.duration ?? 0, 30, within: 100)) } - func testLandscapeImageProcessing() async { - guard let url = Bundle(for: Self.self).url(forResource: "landscape_test_image.jpg", withExtension: nil) else { - XCTFail("Failed retrieving test asset") - return - } + @Test + func landscapeImageProcessing() async throws { + let url = try #require(Bundle(for: Self.self).url(forResource: "landscape_test_image.jpg", withExtension: nil), "Failed retrieving test asset") guard case let .success(result) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize), case let .image(convertedImageURL, thumbnailURL, imageInfo) = result else { - XCTFail("Failed processing asset") + Issue.record("Failed processing asset") return } - compare(originalImageAt: url, toConvertedImageAt: convertedImageURL, withThumbnailAt: thumbnailURL) + try compare(originalImageAt: url, toConvertedImageAt: convertedImageURL, withThumbnailAt: thumbnailURL) // Check resulting image info - XCTAssertEqual(imageInfo.mimetype, "image/jpeg") - XCTAssertEqual(imageInfo.blurhash, "K%I#.NofkC_4ayayxujsWB") - XCTAssertEqual(imageInfo.size ?? 0, 3_305_795, accuracy: 100) - XCTAssertEqual(imageInfo.width, 6103) - XCTAssertEqual(imageInfo.height, 2621) + #expect(imageInfo.mimetype == "image/jpeg") + #expect(imageInfo.blurhash == "K%I#.NofkC_4ayayxujsWB") + #expect(isEqual(imageInfo.size ?? 0, 3_305_795, within: 100)) + #expect(imageInfo.width == 6103) + #expect(imageInfo.height == 2621) - XCTAssertNotNil(imageInfo.thumbnailInfo) - XCTAssertEqual(imageInfo.thumbnailInfo?.mimetype, "image/jpeg") - XCTAssertEqual(imageInfo.thumbnailInfo?.size ?? 0, 87733, accuracy: 100) - XCTAssertEqual(imageInfo.thumbnailInfo?.width, 800) - XCTAssertEqual(imageInfo.thumbnailInfo?.height, 344) + #expect(imageInfo.thumbnailInfo != nil) + #expect(imageInfo.thumbnailInfo?.mimetype == "image/jpeg") + #expect(isEqual(imageInfo.thumbnailInfo?.size ?? 0, 87733, within: 100)) + #expect(imageInfo.thumbnailInfo?.width == 800) + #expect(imageInfo.thumbnailInfo?.height == 344) // Repeat with optimised media setting appSettings.optimizeMediaUploads = true guard case let .success(optimizedResult) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize), case let .image(optimizedImageURL, thumbnailURL, optimizedImageInfo) = optimizedResult else { - XCTFail("Failed processing asset") + Issue.record("Failed processing asset") return } - compare(originalImageAt: url, toConvertedImageAt: optimizedImageURL, withThumbnailAt: thumbnailURL) + try compare(originalImageAt: url, toConvertedImageAt: optimizedImageURL, withThumbnailAt: thumbnailURL) // Check optimised image info - XCTAssertEqual(optimizedImageInfo.mimetype, "image/jpeg") - XCTAssertEqual(optimizedImageInfo.blurhash, "K%I#.NofkC_4ayaxxujsWB") - XCTAssertEqual(optimizedImageInfo.size ?? 0, 524_226, accuracy: 100) - XCTAssertEqual(optimizedImageInfo.width, 2048) - XCTAssertEqual(optimizedImageInfo.height, 879) + #expect(optimizedImageInfo.mimetype == "image/jpeg") + #expect(optimizedImageInfo.blurhash == "K%I#.NofkC_4ayaxxujsWB") + #expect(isEqual(optimizedImageInfo.size ?? 0, 524_226, within: 100)) + #expect(optimizedImageInfo.width == 2048) + #expect(optimizedImageInfo.height == 879) } - func testPortraitImageProcessing() async { - guard let url = Bundle(for: Self.self).url(forResource: "portrait_test_image.jpg", withExtension: nil) else { - XCTFail("Failed retrieving test asset") - return - } + @Test + func portraitImageProcessing() async throws { + let url = try #require(Bundle(for: Self.self).url(forResource: "portrait_test_image.jpg", withExtension: nil), "Failed retrieving test asset") guard case let .success(result) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize), case let .image(convertedImageURL, thumbnailURL, imageInfo) = result else { - XCTFail("Failed processing asset") + Issue.record("Failed processing asset") return } - compare(originalImageAt: url, toConvertedImageAt: convertedImageURL, withThumbnailAt: thumbnailURL) + try compare(originalImageAt: url, toConvertedImageAt: convertedImageURL, withThumbnailAt: thumbnailURL) // Check resulting image info - XCTAssertEqual(imageInfo.mimetype, "image/jpeg") - XCTAssertEqual(imageInfo.blurhash, "KdE|0Ls+RP^-n*RP%OWAV@") - XCTAssertEqual(imageInfo.size ?? 0, 4_414_666, accuracy: 100) - XCTAssertEqual(imageInfo.width, 3024) - XCTAssertEqual(imageInfo.height, 4032) + #expect(imageInfo.mimetype == "image/jpeg") + #expect(imageInfo.blurhash == "KdE|0Ls+RP^-n*RP%OWAV@") + #expect(isEqual(imageInfo.size ?? 0, 4_414_666, within: 100)) + #expect(imageInfo.width == 3024) + #expect(imageInfo.height == 4032) - XCTAssertNotNil(imageInfo.thumbnailInfo) - XCTAssertEqual(imageInfo.thumbnailInfo?.mimetype, "image/jpeg") - XCTAssertEqual(imageInfo.thumbnailInfo?.size ?? 0, 258_914, accuracy: 100) - XCTAssertEqual(imageInfo.thumbnailInfo?.width, 600) - XCTAssertEqual(imageInfo.thumbnailInfo?.height, 800) + #expect(imageInfo.thumbnailInfo != nil) + #expect(imageInfo.thumbnailInfo?.mimetype == "image/jpeg") + #expect(isEqual(imageInfo.thumbnailInfo?.size ?? 0, 258_914, within: 100)) + #expect(imageInfo.thumbnailInfo?.width == 600) + #expect(imageInfo.thumbnailInfo?.height == 800) // Repeat with optimised media setting appSettings.optimizeMediaUploads = true guard case let .success(optimizedResult) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize), case let .image(optimizedImageURL, thumbnailURL, optimizedImageInfo) = optimizedResult else { - XCTFail("Failed processing asset") + Issue.record("Failed processing asset") return } - compare(originalImageAt: url, toConvertedImageAt: optimizedImageURL, withThumbnailAt: thumbnailURL) + try compare(originalImageAt: url, toConvertedImageAt: optimizedImageURL, withThumbnailAt: thumbnailURL) // Check optimised image info - XCTAssertEqual(optimizedImageInfo.mimetype, "image/jpeg") - XCTAssertEqual(optimizedImageInfo.blurhash, "KdE|0Ls+RP^-n*RP%OWAV@") - XCTAssertEqual(optimizedImageInfo.size ?? 0, 1_462_937, accuracy: 100) - XCTAssertEqual(optimizedImageInfo.width, 1536) - XCTAssertEqual(optimizedImageInfo.height, 2048) + #expect(optimizedImageInfo.mimetype == "image/jpeg") + #expect(optimizedImageInfo.blurhash == "KdE|0Ls+RP^-n*RP%OWAV@") + #expect(isEqual(optimizedImageInfo.size ?? 0, 1_462_937, within: 100)) + #expect(optimizedImageInfo.width == 1536) + #expect(optimizedImageInfo.height == 2048) } - func testPNGImageProcessing() async { - guard let url = Bundle(for: Self.self).url(forResource: "test_image.png", withExtension: nil) else { - XCTFail("Failed retrieving test asset") - return - } + @Test + func pngImageProcessing() async throws { + let url = try #require(Bundle(for: Self.self).url(forResource: "test_image.png", withExtension: nil), "Failed retrieving test asset") guard case let .success(result) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize), case let .image(convertedImageURL, _, imageInfo) = result else { - XCTFail("Failed processing asset") + Issue.record("Failed processing asset") return } // Make sure the output file matches the image info. - XCTAssertEqual(mimeType(from: convertedImageURL), "image/png", "PNGs should always be sent as PNG to preserve the alpha channel.") - XCTAssertEqual(convertedImageURL.pathExtension, "png", "The file extension should match the MIME type.") + #expect(mimeType(from: convertedImageURL) == "image/png", "PNGs should always be sent as PNG to preserve the alpha channel.") + #expect(convertedImageURL.pathExtension == "png", "The file extension should match the MIME type.") // Check resulting image info - XCTAssertEqual(imageInfo.mimetype, "image/png") - XCTAssertEqual(imageInfo.blurhash, "K0TSUA~qfQ~qj[fQfQfQfQ") - XCTAssertEqual(imageInfo.size ?? 0, 4868, accuracy: 100) - XCTAssertEqual(imageInfo.width, 240) - XCTAssertEqual(imageInfo.height, 240) + #expect(imageInfo.mimetype == "image/png") + #expect(imageInfo.blurhash == "K0TSUA~qfQ~qj[fQfQfQfQ") + #expect(isEqual(imageInfo.size ?? 0, 4868, within: 100)) + #expect(imageInfo.width == 240) + #expect(imageInfo.height == 240) - XCTAssertNotNil(imageInfo.thumbnailInfo) - XCTAssertEqual(imageInfo.thumbnailInfo?.mimetype, "image/jpeg") - XCTAssertEqual(imageInfo.thumbnailInfo?.size ?? 0, 1725, accuracy: 100) - XCTAssertEqual(imageInfo.thumbnailInfo?.width, 240) - XCTAssertEqual(imageInfo.thumbnailInfo?.height, 240) + #expect(imageInfo.thumbnailInfo != nil) + #expect(imageInfo.thumbnailInfo?.mimetype == "image/jpeg") + #expect(isEqual(imageInfo.thumbnailInfo?.size ?? 0, 1725, within: 100)) + #expect(imageInfo.thumbnailInfo?.width == 240) + #expect(imageInfo.thumbnailInfo?.height == 240) // Repeat with optimised media setting appSettings.optimizeMediaUploads = true guard case let .success(optimizedResult) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize), case let .image(optimizedImageURL, _, optimizedImageInfo) = optimizedResult else { - XCTFail("Failed processing asset") + Issue.record("Failed processing asset") return } // Make sure the output file matches the image info. - XCTAssertEqual(mimeType(from: optimizedImageURL), "image/png", "PNGs should always be sent as PNG to preserve the alpha channel.") - XCTAssertEqual(optimizedImageURL.pathExtension, "png", "The file extension should match the MIME type.") + #expect(mimeType(from: optimizedImageURL) == "image/png", "PNGs should always be sent as PNG to preserve the alpha channel.") + #expect(optimizedImageURL.pathExtension == "png", "The file extension should match the MIME type.") // Check optimised image info - XCTAssertEqual(optimizedImageInfo.mimetype, "image/png") - XCTAssertEqual(optimizedImageInfo.blurhash, "K0TSUA~qfQ~qj[fQfQfQfQ") - XCTAssertEqual(optimizedImageInfo.size ?? 0, 8199, accuracy: 100) + #expect(optimizedImageInfo.mimetype == "image/png") + #expect(optimizedImageInfo.blurhash == "K0TSUA~qfQ~qj[fQfQfQfQ") + #expect(isEqual(optimizedImageInfo.size ?? 0, 8199, within: 100)) // Assert that resizing didn't upscale to the maxPixelSize. - XCTAssertEqual(optimizedImageInfo.width, 240) - XCTAssertEqual(optimizedImageInfo.height, 240) + #expect(optimizedImageInfo.width == 240) + #expect(optimizedImageInfo.height == 240) } - func testHEICImageProcessing() async { - guard let url = Bundle(for: Self.self).url(forResource: "test_apple_image.heic", withExtension: nil) else { - XCTFail("Failed retrieving test asset") - return - } + @Test + func heicImageProcessing() async throws { + let url = try #require(Bundle(for: Self.self).url(forResource: "test_apple_image.heic", withExtension: nil), "Failed retrieving test asset") guard case let .success(result) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize), case let .image(convertedImageURL, thumbnailURL, imageInfo) = result else { - XCTFail("Failed processing asset") + Issue.record("Failed processing asset") return } - compare(originalImageAt: url, toConvertedImageAt: convertedImageURL, withThumbnailAt: thumbnailURL) + try compare(originalImageAt: url, toConvertedImageAt: convertedImageURL, withThumbnailAt: thumbnailURL) // Make sure the output file matches the image info. - XCTAssertEqual(mimeType(from: convertedImageURL), "image/heic", "Unoptimised HEICs should always be sent as is.") - XCTAssertEqual(convertedImageURL.pathExtension, "heic", "The file extension should match the MIME type.") + #expect(mimeType(from: convertedImageURL) == "image/heic", "Unoptimised HEICs should always be sent as is.") + #expect(convertedImageURL.pathExtension == "heic", "The file extension should match the MIME type.") // Check resulting image info - XCTAssertEqual(imageInfo.mimetype, "image/heic") - XCTAssertEqual(imageInfo.blurhash, "KGD]3ns:T00$kWxFXmt6xv") - XCTAssertEqual(imageInfo.size ?? 0, 1_848_525, accuracy: 100) - XCTAssertEqual(imageInfo.width, 3024) - XCTAssertEqual(imageInfo.height, 4032) + #expect(imageInfo.mimetype == "image/heic") + #expect(imageInfo.blurhash == "KGD]3ns:T00$kWxFXmt6xv") + #expect(isEqual(imageInfo.size ?? 0, 1_848_525, within: 100)) + #expect(imageInfo.width == 3024) + #expect(imageInfo.height == 4032) - XCTAssertNotNil(imageInfo.thumbnailInfo) - XCTAssertEqual(imageInfo.thumbnailInfo?.mimetype, "image/jpeg") - XCTAssertEqual(imageInfo.thumbnailInfo?.size ?? 0, 218_108, accuracy: 100) - XCTAssertEqual(imageInfo.thumbnailInfo?.width, 600) - XCTAssertEqual(imageInfo.thumbnailInfo?.height, 800) + #expect(imageInfo.thumbnailInfo != nil) + #expect(imageInfo.thumbnailInfo?.mimetype == "image/jpeg") + #expect(isEqual(imageInfo.thumbnailInfo?.size ?? 0, 218_108, within: 100)) + #expect(imageInfo.thumbnailInfo?.width == 600) + #expect(imageInfo.thumbnailInfo?.height == 800) // Repeat with optimised media setting appSettings.optimizeMediaUploads = true guard case let .success(optimizedResult) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize), case let .image(optimizedImageURL, thumbnailURL, optimizedImageInfo) = optimizedResult else { - XCTFail("Failed processing asset") + Issue.record("Failed processing asset") return } - compare(originalImageAt: url, toConvertedImageAt: optimizedImageURL, withThumbnailAt: thumbnailURL) + try compare(originalImageAt: url, toConvertedImageAt: optimizedImageURL, withThumbnailAt: thumbnailURL) // Make sure the output file matches the image info. - XCTAssertEqual(mimeType(from: optimizedImageURL), "image/jpeg", "Optimised HEICs should always be converted to JPEG for compatibility.") - XCTAssertEqual(optimizedImageURL.pathExtension, "jpeg", "The file extension should match the MIME type.") + #expect(mimeType(from: optimizedImageURL) == "image/jpeg", "Optimised HEICs should always be converted to JPEG for compatibility.") + #expect(optimizedImageURL.pathExtension == "jpeg", "The file extension should match the MIME type.") // Check optimised image info - XCTAssertEqual(optimizedImageInfo.mimetype, "image/jpeg") - XCTAssertEqual(optimizedImageInfo.blurhash, "KGD]3ns:T00#kWxFb^s:xv") - XCTAssertEqual(optimizedImageInfo.size ?? 0, 1_049_393, accuracy: 100) - XCTAssertEqual(optimizedImageInfo.width, 1536) - XCTAssertEqual(optimizedImageInfo.height, 2048) + #expect(optimizedImageInfo.mimetype == "image/jpeg") + #expect(optimizedImageInfo.blurhash == "KGD]3ns:T00#kWxFb^s:xv") + #expect(isEqual(optimizedImageInfo.size ?? 0, 1_049_393, within: 100)) + #expect(optimizedImageInfo.width == 1536) + #expect(optimizedImageInfo.height == 2048) } - func testGIFImageProcessing() async { - guard let url = Bundle(for: Self.self).url(forResource: "test_animated_image.gif", withExtension: nil) else { - XCTFail("Failed retrieving test asset") - return - } - guard let originalSize = try? FileManager.default.sizeForItem(at: url), originalSize > 0 else { - XCTFail("Failed fetching test asset's original size") - return - } + @Test + func gifImageProcessing() async throws { + let url = try #require(Bundle(for: Self.self).url(forResource: "test_animated_image.gif", withExtension: nil), "Failed retrieving test asset") + let originalSizeValue = try UInt64(FileManager.default.sizeForItem(at: url)) + let originalSize = try #require(originalSizeValue > 0 ? originalSizeValue : nil, "File size must be greater than zero") guard case let .success(result) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize), case let .image(convertedImageURL, _, imageInfo) = result else { - XCTFail("Failed processing asset") + Issue.record("Failed processing asset") return } // Make sure the output file matches the image info. - XCTAssertEqual(mimeType(from: convertedImageURL), "image/gif", "GIFs should always be sent as GIF to preserve the animation.") - XCTAssertEqual(convertedImageURL.pathExtension, "gif", "The file extension should match the MIME type.") + #expect(mimeType(from: convertedImageURL) == "image/gif", "GIFs should always be sent as GIF to preserve the animation.") + #expect(convertedImageURL.pathExtension == "gif", "The file extension should match the MIME type.") // Check resulting image info - XCTAssertEqual(imageInfo.mimetype, "image/gif") - XCTAssertEqual(imageInfo.blurhash, "KpRMPTj[_NxuaeRj%MofMx") - XCTAssertEqual(imageInfo.size ?? 0, UInt64(originalSize), accuracy: 100) - XCTAssertEqual(imageInfo.width, 331) - XCTAssertEqual(imageInfo.height, 472) + #expect(imageInfo.mimetype == "image/gif") + #expect(imageInfo.blurhash == "KpRMPTj[_NxuaeRj%MofMx") + #expect(isEqual(imageInfo.size ?? 0, originalSize, within: 100)) + #expect(imageInfo.width == 331) + #expect(imageInfo.height == 472) - XCTAssertNotNil(imageInfo.thumbnailInfo) - XCTAssertEqual(imageInfo.thumbnailInfo?.mimetype, "image/jpeg") - XCTAssertEqual(imageInfo.thumbnailInfo?.size ?? 0, 34215, accuracy: 100) - XCTAssertEqual(imageInfo.thumbnailInfo?.width, 331) - XCTAssertEqual(imageInfo.thumbnailInfo?.height, 472) + #expect(imageInfo.thumbnailInfo != nil) + #expect(imageInfo.thumbnailInfo?.mimetype == "image/jpeg") + #expect(isEqual(imageInfo.thumbnailInfo?.size ?? 0, 34215, within: 100)) + #expect(imageInfo.thumbnailInfo?.width == 331) + #expect(imageInfo.thumbnailInfo?.height == 472) // Repeat with optimised media setting appSettings.optimizeMediaUploads = true guard case let .success(optimizedResult) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize), case let .image(optimizedImageURL, _, optimizedImageInfo) = optimizedResult else { - XCTFail("Failed processing asset") + Issue.record("Failed processing asset") return } // Make sure the output file matches the image info. - XCTAssertEqual(mimeType(from: optimizedImageURL), "image/gif", "GIFs should always be sent as GIF to preserve the animation.") - XCTAssertEqual(optimizedImageURL.pathExtension, "gif", "The file extension should match the MIME type.") + #expect(mimeType(from: optimizedImageURL) == "image/gif", "GIFs should always be sent as GIF to preserve the animation.") + #expect(optimizedImageURL.pathExtension == "gif", "The file extension should match the MIME type.") // Ensure optimised image is still the same as the original image. - XCTAssertEqual(optimizedImageInfo.mimetype, "image/gif") - XCTAssertEqual(optimizedImageInfo.blurhash, "KpRMPTj[_NxuaeRj%MofMx") - XCTAssertEqual(optimizedImageInfo.size ?? 0, UInt64(originalSize), accuracy: 100) - XCTAssertEqual(optimizedImageInfo.width, 331) - XCTAssertEqual(optimizedImageInfo.height, 472) + #expect(optimizedImageInfo.mimetype == "image/gif") + #expect(optimizedImageInfo.blurhash == "KpRMPTj[_NxuaeRj%MofMx") + #expect(isEqual(optimizedImageInfo.size ?? 0, originalSize, within: 100)) + #expect(optimizedImageInfo.width == 331) + #expect(optimizedImageInfo.height == 472) } - func testRotatedImageProcessing() async { - guard let url = Bundle(for: Self.self).url(forResource: "test_rotated_image.jpg", withExtension: nil) else { - XCTFail("Failed retrieving test asset") - return - } + @Test + func rotatedImageProcessing() async throws { + let url = try #require(Bundle(for: Self.self).url(forResource: "test_rotated_image.jpg", withExtension: nil), "Failed retrieving test asset") guard case let .success(result) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize), case let .image(convertedImageURL, thumbnailURL, imageInfo) = result else { - XCTFail("Failed processing asset") + Issue.record("Failed processing asset") return } - compare(originalImageAt: url, toConvertedImageAt: convertedImageURL, withThumbnailAt: thumbnailURL) + try compare(originalImageAt: url, toConvertedImageAt: convertedImageURL, withThumbnailAt: thumbnailURL) // Check resulting image info - XCTAssertEqual(imageInfo.mimetype, "image/jpeg") - XCTAssertEqual(imageInfo.width, 2848) - XCTAssertEqual(imageInfo.height, 4272) + #expect(imageInfo.mimetype == "image/jpeg") + #expect(imageInfo.width == 2848) + #expect(imageInfo.height == 4272) - XCTAssertNotNil(imageInfo.thumbnailInfo) - XCTAssertEqual(imageInfo.thumbnailInfo?.width, 533) - XCTAssertEqual(imageInfo.thumbnailInfo?.height, 800) + #expect(imageInfo.thumbnailInfo != nil) + #expect(imageInfo.thumbnailInfo?.width == 533) + #expect(imageInfo.thumbnailInfo?.height == 800) // Repeat with optimised media setting appSettings.optimizeMediaUploads = true guard case let .success(optimizedResult) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize), case let .image(optimizedImageURL, thumbnailURL, optimizedImageInfo) = optimizedResult else { - XCTFail("Failed processing asset") + Issue.record("Failed processing asset") return } - compare(originalImageAt: url, toConvertedImageAt: optimizedImageURL, withThumbnailAt: thumbnailURL) + try compare(originalImageAt: url, toConvertedImageAt: optimizedImageURL, withThumbnailAt: thumbnailURL) // Check optimised image info - XCTAssertEqual(optimizedImageInfo.mimetype, "image/jpeg") - XCTAssertEqual(optimizedImageInfo.width, 1365) - XCTAssertEqual(optimizedImageInfo.height, 2048) + #expect(optimizedImageInfo.mimetype == "image/jpeg") + #expect(optimizedImageInfo.width == 1365) + #expect(optimizedImageInfo.height == 2048) } // MARK: - Private - private func compare(originalImageAt originalImageURL: URL, toConvertedImageAt convertedImageURL: URL, withThumbnailAt thumbnailURL: URL) { + private func isEqual(_ lhs: N, _ rhs: N, within tolerance: N) -> Bool { + isEqual(Double(lhs), Double(rhs), within: Double(tolerance)) + } + + private func isEqual(_ lhs: N, _ rhs: N, within tolerance: N) -> Bool { + abs(lhs - rhs) <= tolerance + } + + private func compare(originalImageAt originalImageURL: URL, toConvertedImageAt convertedImageURL: URL, withThumbnailAt thumbnailURL: URL) throws { guard let originalImageData = try? Data(contentsOf: originalImageURL), let originalImage = UIImage(data: originalImageData), let convertedImageData = try? Data(contentsOf: convertedImageURL), @@ -476,53 +454,41 @@ final class MediaUploadingPreprocessorTests: XCTestCase { if appSettings.optimizeMediaUploads { // Check that new image has been scaled within the requirements for an optimised image - XCTAssert(convertedImage.size.width <= MediaUploadingPreprocessor.Constants.optimizedMaxPixelSize) - XCTAssert(convertedImage.size.height <= MediaUploadingPreprocessor.Constants.optimizedMaxPixelSize) + #expect(convertedImage.size.width <= MediaUploadingPreprocessor.Constants.optimizedMaxPixelSize) + #expect(convertedImage.size.height <= MediaUploadingPreprocessor.Constants.optimizedMaxPixelSize) } else { // Check that the file name is preserved - XCTAssertEqual(originalImageURL.lastPathComponent, convertedImageURL.lastPathComponent) + #expect(originalImageURL.lastPathComponent == convertedImageURL.lastPathComponent) // Check that new image is the same size as the original one - XCTAssertEqual(originalImage.size, convertedImage.size) + #expect(originalImage.size == convertedImage.size) } // Check that the GPS data has been stripped - let originalMetadata = metadata(from: originalImageData) - XCTAssertNotNil(originalMetadata.value(forKeyPath: "\(kCGImagePropertyGPSDictionary)")) + let originalMetadata = try metadata(from: originalImageData) + #expect(originalMetadata.value(forKeyPath: "\(kCGImagePropertyGPSDictionary)") != nil) - let convertedMetadata = metadata(from: convertedImageData) - XCTAssertNil(convertedMetadata.value(forKeyPath: "\(kCGImagePropertyGPSDictionary)")) + let convertedMetadata = try metadata(from: convertedImageData) + #expect(convertedMetadata.value(forKeyPath: "\(kCGImagePropertyGPSDictionary)") == nil) // Check that the thumbnail is generated correctly - guard let thumbnailData = try? Data(contentsOf: thumbnailURL), - let thumbnail = UIImage(data: thumbnailData) else { - XCTFail("Invalid thumbnail") - return - } + let thumbnailData = try Data(contentsOf: thumbnailURL) + let thumbnail = try #require(UIImage(data: thumbnailData), "Invalid thumbnail") if thumbnail.size.width > thumbnail.size.height { - XCTAssert(thumbnail.size.width <= MediaUploadingPreprocessor.Constants.maximumThumbnailSize.width) - XCTAssert(thumbnail.size.height <= MediaUploadingPreprocessor.Constants.maximumThumbnailSize.height) + #expect(thumbnail.size.width <= MediaUploadingPreprocessor.Constants.maximumThumbnailSize.width) + #expect(thumbnail.size.height <= MediaUploadingPreprocessor.Constants.maximumThumbnailSize.height) } else { - XCTAssert(thumbnail.size.width <= MediaUploadingPreprocessor.Constants.maximumThumbnailSize.height) - XCTAssert(thumbnail.size.height <= MediaUploadingPreprocessor.Constants.maximumThumbnailSize.width) + #expect(thumbnail.size.width <= MediaUploadingPreprocessor.Constants.maximumThumbnailSize.height) + #expect(thumbnail.size.height <= MediaUploadingPreprocessor.Constants.maximumThumbnailSize.width) } - let thumbnailMetadata = metadata(from: thumbnailData) - XCTAssertNil(thumbnailMetadata.value(forKeyPath: "\(kCGImagePropertyGPSDictionary)")) + let thumbnailMetadata = try metadata(from: thumbnailData) + #expect(thumbnailMetadata.value(forKeyPath: "\(kCGImagePropertyGPSDictionary)") == nil) } - private func metadata(from imageData: Data) -> NSDictionary { - guard let imageSource = CGImageSourceCreateWithData(imageData as NSData, nil) else { - XCTFail("Invalid asset") - return [:] - } - - guard let convertedMetadata: NSDictionary = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) else { - XCTFail("Test asset is expected to contain metadata") - return [:] - } - - return convertedMetadata + private func metadata(from imageData: Data) throws -> NSDictionary { + let imageSource = try #require(CGImageSourceCreateWithData(imageData as NSData, nil), "Invalid asset") + return try #require(CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as NSDictionary?, "Test asset is expected to contain metadata") } private func mimeType(from url: URL) -> String? { @@ -530,7 +496,7 @@ final class MediaUploadingPreprocessorTests: XCTestCase { let typeIdentifier = CGImageSourceGetType(imageSource), let type = UTType(typeIdentifier as String), let mimeType = type.preferredMIMEType else { - XCTFail("Failed to get mimetype from URL.") + Issue.record("Failed to get mimetype from URL.") return nil } return mimeType diff --git a/UnitTests/Sources/NavigationSplitCoordinatorTests.swift b/UnitTests/Sources/NavigationSplitCoordinatorTests.swift index 70334adf5..4530ac98a 100644 --- a/UnitTests/Sources/NavigationSplitCoordinatorTests.swift +++ b/UnitTests/Sources/NavigationSplitCoordinatorTests.swift @@ -7,37 +7,42 @@ // @testable import ElementX -import XCTest +import Foundation +import Testing +@Suite @MainActor -class NavigationSplitCoordinatorTests: XCTestCase { - private var navigationSplitCoordinator: NavigationSplitCoordinator! +struct NavigationSplitCoordinatorTests { + private var navigationSplitCoordinator: NavigationSplitCoordinator - override func setUp() { + init() { navigationSplitCoordinator = NavigationSplitCoordinator(placeholderCoordinator: SomeTestCoordinator()) } - func testSidebar() { - XCTAssertNil(navigationSplitCoordinator.sidebarCoordinator) - XCTAssertNil(navigationSplitCoordinator.detailCoordinator) + @Test + func sidebar() { + #expect(navigationSplitCoordinator.sidebarCoordinator == nil) + #expect(navigationSplitCoordinator.detailCoordinator == nil) let sidebarCoordinator = SomeTestCoordinator() navigationSplitCoordinator.setSidebarCoordinator(sidebarCoordinator) assertCoordinatorsEqual(sidebarCoordinator, navigationSplitCoordinator.sidebarCoordinator) } - func testDetail() { - XCTAssertNil(navigationSplitCoordinator.sidebarCoordinator) - XCTAssertNil(navigationSplitCoordinator.detailCoordinator) + @Test + func detail() { + #expect(navigationSplitCoordinator.sidebarCoordinator == nil) + #expect(navigationSplitCoordinator.detailCoordinator == nil) let detailCoordinator = SomeTestCoordinator() navigationSplitCoordinator.setDetailCoordinator(detailCoordinator) assertCoordinatorsEqual(detailCoordinator, navigationSplitCoordinator.detailCoordinator) } - func testSidebarAndDetail() { - XCTAssertNil(navigationSplitCoordinator.sidebarCoordinator) - XCTAssertNil(navigationSplitCoordinator.detailCoordinator) + @Test + func sidebarAndDetail() { + #expect(navigationSplitCoordinator.sidebarCoordinator == nil) + #expect(navigationSplitCoordinator.detailCoordinator == nil) let sidebarCoordinator = SomeTestCoordinator() navigationSplitCoordinator.setSidebarCoordinator(sidebarCoordinator) @@ -49,7 +54,8 @@ class NavigationSplitCoordinatorTests: XCTestCase { assertCoordinatorsEqual(detailCoordinator, navigationSplitCoordinator.detailCoordinator) } - func testSingleSheet() { + @Test + func singleSheet() { let sidebarCoordinator = SomeTestCoordinator() navigationSplitCoordinator.setSidebarCoordinator(sidebarCoordinator) let detailCoordinator = SomeTestCoordinator() @@ -66,10 +72,11 @@ class NavigationSplitCoordinatorTests: XCTestCase { assertCoordinatorsEqual(sidebarCoordinator, navigationSplitCoordinator.sidebarCoordinator) assertCoordinatorsEqual(detailCoordinator, navigationSplitCoordinator.detailCoordinator) - XCTAssertNil(navigationSplitCoordinator.sheetCoordinator) + #expect(navigationSplitCoordinator.sheetCoordinator == nil) } - func testMultipleSheets() { + @Test + func multipleSheets() { let sidebarCoordinator = SomeTestCoordinator() navigationSplitCoordinator.setSidebarCoordinator(sidebarCoordinator) let detailCoordinator = SomeTestCoordinator() @@ -90,7 +97,8 @@ class NavigationSplitCoordinatorTests: XCTestCase { assertCoordinatorsEqual(someOtherSheetCoordinator, navigationSplitCoordinator.sheetCoordinator) } - func testFullScreenCover() { + @Test + func fullScreenCover() { let sidebarCoordinator = SomeTestCoordinator() navigationSplitCoordinator.setSidebarCoordinator(sidebarCoordinator) let detailCoordinator = SomeTestCoordinator() @@ -107,62 +115,67 @@ class NavigationSplitCoordinatorTests: XCTestCase { assertCoordinatorsEqual(sidebarCoordinator, navigationSplitCoordinator.sidebarCoordinator) assertCoordinatorsEqual(detailCoordinator, navigationSplitCoordinator.detailCoordinator) - XCTAssertNil(navigationSplitCoordinator.fullScreenCoverCoordinator) + #expect(navigationSplitCoordinator.fullScreenCoverCoordinator == nil) } // MARK: - Dismissal Callbacks - func testSidebarReplacementCallbacks() { + @Test + func sidebarReplacementCallbacks() async { let sidebarCoordinator = SomeTestCoordinator() - let expectation = expectation(description: "Wait for callback") - navigationSplitCoordinator.setSidebarCoordinator(sidebarCoordinator) { - expectation.fulfill() + await waitForConfirmation("Wait for callback", timeout: .seconds(1)) { confirm in + navigationSplitCoordinator.setSidebarCoordinator(sidebarCoordinator) { + confirm() + } + + navigationSplitCoordinator.setSidebarCoordinator(nil) } - - navigationSplitCoordinator.setSidebarCoordinator(nil) - waitForExpectations(timeout: 1.0) } - func testDetailReplacementCallbacks() { + @Test + func detailReplacementCallbacks() async { let detailCoordinator = SomeTestCoordinator() - let expectation = expectation(description: "Wait for callback") - navigationSplitCoordinator.setDetailCoordinator(detailCoordinator) { - expectation.fulfill() + await waitForConfirmation("Wait for callback", timeout: .seconds(1)) { confirm in + navigationSplitCoordinator.setDetailCoordinator(detailCoordinator) { + confirm() + } + + navigationSplitCoordinator.setDetailCoordinator(nil) } - - navigationSplitCoordinator.setDetailCoordinator(nil) - waitForExpectations(timeout: 1.0) } - func testSheetDismissalCallback() { + @Test + func sheetDismissalCallback() async { let sheetCoordinator = SomeTestCoordinator() - let expectation = expectation(description: "Wait for callback") - navigationSplitCoordinator.setSheetCoordinator(sheetCoordinator) { - expectation.fulfill() + await waitForConfirmation("Wait for callback", timeout: .seconds(1)) { confirm in + navigationSplitCoordinator.setSheetCoordinator(sheetCoordinator) { + confirm() + } + + navigationSplitCoordinator.setSheetCoordinator(nil) } - - navigationSplitCoordinator.setSheetCoordinator(nil) - waitForExpectations(timeout: 1.0) } - func testFullScreenCoverDismissalCallback() { + @Test + func fullScreenCoverDismissalCallback() async { let fullScreenCoordinator = SomeTestCoordinator() - let expectation = expectation(description: "Wait for callback") - navigationSplitCoordinator.setFullScreenCoverCoordinator(fullScreenCoordinator) { - expectation.fulfill() + await waitForConfirmation("Wait for callback", timeout: .seconds(1)) { confirm in + navigationSplitCoordinator.setFullScreenCoverCoordinator(fullScreenCoordinator) { + confirm() + } + + navigationSplitCoordinator.setFullScreenCoverCoordinator(nil) } - - navigationSplitCoordinator.setFullScreenCoverCoordinator(nil) - waitForExpectations(timeout: 1.0) } // MARK: - Advanced - func testEmbeddedStackPresentsSheetThroughSplit() { + @Test + func embeddedStackPresentsSheetThroughSplit() { let sidebarNavigationStackCoordinator = NavigationStackCoordinator(navigationSplitCoordinator: navigationSplitCoordinator) sidebarNavigationStackCoordinator.setRootCoordinator(SomeTestCoordinator()) @@ -175,7 +188,8 @@ class NavigationSplitCoordinatorTests: XCTestCase { assertCoordinatorsEqual(sheetCoordinator, navigationSplitCoordinator.sheetCoordinator) } - func testSplitTracksEmbeddedStackRootChanges() { + @Test + func splitTracksEmbeddedStackRootChanges() async { let sidebarNavigationStackCoordinator = NavigationStackCoordinator(navigationSplitCoordinator: navigationSplitCoordinator) sidebarNavigationStackCoordinator.setRootCoordinator(SomeTestCoordinator()) @@ -185,36 +199,38 @@ class NavigationSplitCoordinatorTests: XCTestCase { sidebarNavigationStackCoordinator.setRootCoordinator(SomeTestCoordinator()) - let expectation = expectation(description: "Coordinators should match") - DispatchQueue.main.async { - self.assertCoordinatorsEqual(sidebarNavigationStackCoordinator.rootCoordinator, self.navigationSplitCoordinator.compactLayoutRootCoordinator) - expectation.fulfill() - } - waitForExpectations(timeout: 1.0) - } - - func testSplitTracksEmbeddedStackChanges() { - let sidebarNavigationStackCoordinator = NavigationStackCoordinator(navigationSplitCoordinator: navigationSplitCoordinator) - sidebarNavigationStackCoordinator.setRootCoordinator(SomeTestCoordinator()) - - navigationSplitCoordinator.setSidebarCoordinator(sidebarNavigationStackCoordinator) - - assertCoordinatorsEqual(sidebarNavigationStackCoordinator.rootCoordinator, navigationSplitCoordinator.compactLayoutRootCoordinator) - - sidebarNavigationStackCoordinator.push(SomeTestCoordinator()) - - let expectation = expectation(description: "Coordinators should match") - DispatchQueue.main.async { - XCTAssertEqual(sidebarNavigationStackCoordinator.stackCoordinators.count, self.navigationSplitCoordinator.compactLayoutStackCoordinators.count) - for index in sidebarNavigationStackCoordinator.stackCoordinators.indices { - self.assertCoordinatorsEqual(sidebarNavigationStackCoordinator.stackCoordinators[index], self.navigationSplitCoordinator.compactLayoutStackCoordinators[index]) + await waitForConfirmation("Coordinators should match", timeout: .seconds(1)) { confirm in + DispatchQueue.main.async { + assertCoordinatorsEqual(sidebarNavigationStackCoordinator.rootCoordinator, navigationSplitCoordinator.compactLayoutRootCoordinator) + confirm() } - expectation.fulfill() } - waitForExpectations(timeout: 1.0) } - func testSplitPropagatesCompactStackChanges() { + @Test + func splitTracksEmbeddedStackChanges() async { + let sidebarNavigationStackCoordinator = NavigationStackCoordinator(navigationSplitCoordinator: navigationSplitCoordinator) + sidebarNavigationStackCoordinator.setRootCoordinator(SomeTestCoordinator()) + + navigationSplitCoordinator.setSidebarCoordinator(sidebarNavigationStackCoordinator) + + assertCoordinatorsEqual(sidebarNavigationStackCoordinator.rootCoordinator, navigationSplitCoordinator.compactLayoutRootCoordinator) + + sidebarNavigationStackCoordinator.push(SomeTestCoordinator()) + + await waitForConfirmation("Coordinators should match", timeout: .seconds(1)) { confirm in + DispatchQueue.main.async { + #expect(sidebarNavigationStackCoordinator.stackCoordinators.count == navigationSplitCoordinator.compactLayoutStackCoordinators.count) + for index in sidebarNavigationStackCoordinator.stackCoordinators.indices { + assertCoordinatorsEqual(sidebarNavigationStackCoordinator.stackCoordinators[index], navigationSplitCoordinator.compactLayoutStackCoordinators[index]) + } + confirm() + } + } + } + + @Test + func splitPropagatesCompactStackChanges() { let sidebarNavigationStackCoordinator = NavigationStackCoordinator(navigationSplitCoordinator: navigationSplitCoordinator) sidebarNavigationStackCoordinator.setRootCoordinator(SomeTestCoordinator()) sidebarNavigationStackCoordinator.push(SomeTestCoordinator()) @@ -222,14 +238,15 @@ class NavigationSplitCoordinatorTests: XCTestCase { navigationSplitCoordinator.setSidebarCoordinator(sidebarNavigationStackCoordinator) assertCoordinatorsEqual(sidebarNavigationStackCoordinator.rootCoordinator, navigationSplitCoordinator.compactLayoutRootCoordinator) - XCTAssertEqual(sidebarNavigationStackCoordinator.stackCoordinators.count, navigationSplitCoordinator.compactLayoutStackCoordinators.count) + #expect(sidebarNavigationStackCoordinator.stackCoordinators.count == navigationSplitCoordinator.compactLayoutStackCoordinators.count) navigationSplitCoordinator.compactLayoutStackModules.removeAll() - XCTAssertTrue(sidebarNavigationStackCoordinator.stackCoordinators.isEmpty) + #expect(sidebarNavigationStackCoordinator.stackCoordinators.isEmpty) } - func testCompactStackCreation() { + @Test + func compactStackCreation() async { let sidebarCoordinator = NavigationStackCoordinator() sidebarCoordinator.setRootCoordinator(SomeTestCoordinator()) sidebarCoordinator.push(SomeTestCoordinator()) @@ -242,19 +259,20 @@ class NavigationSplitCoordinatorTests: XCTestCase { navigationSplitCoordinator.setSidebarCoordinator(sidebarCoordinator) navigationSplitCoordinator.setDetailCoordinator(detailCoordinator) - let expectation = expectation(description: "Coordinators should match") - DispatchQueue.main.async { - self.assertCoordinatorsEqual(self.navigationSplitCoordinator.compactLayoutRootCoordinator, sidebarCoordinator.rootCoordinator) - self.assertCoordinatorsEqual(self.navigationSplitCoordinator.compactLayoutStackModules[0].coordinator, sidebarCoordinator.stackCoordinators.first) - self.assertCoordinatorsEqual(self.navigationSplitCoordinator.compactLayoutStackModules[1].coordinator, detailCoordinator.rootCoordinator) - self.assertCoordinatorsEqual(self.navigationSplitCoordinator.compactLayoutStackModules[2].coordinator, detailCoordinator.stackCoordinators.first) - self.assertCoordinatorsEqual(self.navigationSplitCoordinator.compactLayoutStackModules[3].coordinator, detailCoordinator.stackCoordinators.last) - expectation.fulfill() + await waitForConfirmation("Coordinators should match", timeout: .seconds(1)) { confirm in + DispatchQueue.main.async { + assertCoordinatorsEqual(navigationSplitCoordinator.compactLayoutRootCoordinator, sidebarCoordinator.rootCoordinator) + assertCoordinatorsEqual(navigationSplitCoordinator.compactLayoutStackModules[0].coordinator, sidebarCoordinator.stackCoordinators.first) + assertCoordinatorsEqual(navigationSplitCoordinator.compactLayoutStackModules[1].coordinator, detailCoordinator.rootCoordinator) + assertCoordinatorsEqual(navigationSplitCoordinator.compactLayoutStackModules[2].coordinator, detailCoordinator.stackCoordinators.first) + assertCoordinatorsEqual(navigationSplitCoordinator.compactLayoutStackModules[3].coordinator, detailCoordinator.stackCoordinators.last) + confirm() + } } - waitForExpectations(timeout: 1.0) } - func testRemovesDetailRootFromCompactStack() { + @Test + func removesDetailRootFromCompactStack() async { let sidebarCoordinator = NavigationStackCoordinator() sidebarCoordinator.setRootCoordinator(SomeTestCoordinator()) @@ -265,25 +283,26 @@ class NavigationSplitCoordinatorTests: XCTestCase { navigationSplitCoordinator.setSidebarCoordinator(sidebarCoordinator) navigationSplitCoordinator.setDetailCoordinator(detailCoordinator) - let expectation = expectation(description: "Coordinators should match") - DispatchQueue.main.async { - self.assertCoordinatorsEqual(self.navigationSplitCoordinator.compactLayoutRootCoordinator, sidebarCoordinator.rootCoordinator) - self.assertCoordinatorsEqual(self.navigationSplitCoordinator.compactLayoutStackModules[0].coordinator, detailCoordinator.rootCoordinator) - self.assertCoordinatorsEqual(self.navigationSplitCoordinator.compactLayoutStackModules[1].coordinator, detailCoordinator.stackCoordinators.first) - - detailCoordinator.setRootCoordinator(nil) - + await waitForConfirmation("Coordinators should match", timeout: .seconds(1)) { confirm in DispatchQueue.main.async { - self.assertCoordinatorsEqual(self.navigationSplitCoordinator.compactLayoutRootCoordinator, sidebarCoordinator.rootCoordinator) - self.assertCoordinatorsEqual(self.navigationSplitCoordinator.compactLayoutStackModules[0].coordinator, detailCoordinator.stackCoordinators.first) + assertCoordinatorsEqual(navigationSplitCoordinator.compactLayoutRootCoordinator, sidebarCoordinator.rootCoordinator) + assertCoordinatorsEqual(navigationSplitCoordinator.compactLayoutStackModules[0].coordinator, detailCoordinator.rootCoordinator) + assertCoordinatorsEqual(navigationSplitCoordinator.compactLayoutStackModules[1].coordinator, detailCoordinator.stackCoordinators.first) + + detailCoordinator.setRootCoordinator(nil) + + DispatchQueue.main.async { + assertCoordinatorsEqual(navigationSplitCoordinator.compactLayoutRootCoordinator, sidebarCoordinator.rootCoordinator) + assertCoordinatorsEqual(navigationSplitCoordinator.compactLayoutStackModules[0].coordinator, detailCoordinator.stackCoordinators.first) + } + + confirm() } - - expectation.fulfill() } - waitForExpectations(timeout: 1.0) } - func testSetRootDetailToNilAfterPoppingToRoot() { + @Test + mutating func setRootDetailToNilAfterPoppingToRoot() async { navigationSplitCoordinator = NavigationSplitCoordinator(placeholderCoordinator: SomeTestCoordinator()) let sidebarCoordinator = NavigationStackCoordinator() sidebarCoordinator.setRootCoordinator(SomeTestCoordinator()) @@ -295,35 +314,36 @@ class NavigationSplitCoordinatorTests: XCTestCase { navigationSplitCoordinator.setSidebarCoordinator(sidebarCoordinator) navigationSplitCoordinator.setDetailCoordinator(detailCoordinator) - let expectation = expectation(description: "Details coordinator should be nil, and the compact layout revert to the sidebar root") - DispatchQueue.main.async { - detailCoordinator.popToRoot(animated: true) - self.navigationSplitCoordinator.setDetailCoordinator(nil) + await waitForConfirmation("Details coordinator should be nil, and the compact layout revert to the sidebar root", + timeout: .seconds(1)) { [navigationSplitCoordinator] confirm in DispatchQueue.main.async { - XCTAssertNil(self.navigationSplitCoordinator.detailCoordinator) - self.assertCoordinatorsEqual(self.navigationSplitCoordinator.compactLayoutRootCoordinator, sidebarCoordinator.rootCoordinator) - XCTAssertTrue(self.navigationSplitCoordinator.compactLayoutStackModules.isEmpty) - expectation.fulfill() + detailCoordinator.popToRoot(animated: true) + navigationSplitCoordinator.setDetailCoordinator(nil) + DispatchQueue.main.async { + #expect(navigationSplitCoordinator.detailCoordinator == nil) + assertCoordinatorsEqual(navigationSplitCoordinator.compactLayoutRootCoordinator, sidebarCoordinator.rootCoordinator) + #expect(navigationSplitCoordinator.compactLayoutStackModules.isEmpty) + confirm() + } } } - waitForExpectations(timeout: 1.0) + } +} + +// MARK: - Private + +private func assertCoordinatorsEqual(_ lhs: CoordinatorProtocol?, _ rhs: CoordinatorProtocol?) { + if lhs == nil, rhs == nil { + return } - // MARK: - Private - - private func assertCoordinatorsEqual(_ lhs: CoordinatorProtocol?, _ rhs: CoordinatorProtocol?) { - if lhs == nil, rhs == nil { - return - } - - guard let lhs = lhs as? SomeTestCoordinator, - let rhs = rhs as? SomeTestCoordinator else { - XCTFail("Coordinators are not the same: \(String(describing: lhs)) != \(String(describing: rhs))") - return - } - - XCTAssertEqual(lhs.id, rhs.id) + guard let lhs = lhs as? SomeTestCoordinator, + let rhs = rhs as? SomeTestCoordinator else { + Issue.record("Coordinators are not the same: \(String(describing: lhs)) != \(String(describing: rhs))") + return } + + #expect(lhs.id == rhs.id) } private class SomeTestCoordinator: CoordinatorProtocol { diff --git a/UnitTests/Sources/NotificationManager/NotificationManagerTests.swift b/UnitTests/Sources/NotificationManager/NotificationManagerTests.swift index b1021404f..fb4e32522 100644 --- a/UnitTests/Sources/NotificationManager/NotificationManagerTests.swift +++ b/UnitTests/Sources/NotificationManager/NotificationManagerTests.swift @@ -9,10 +9,11 @@ import Combine @testable import ElementX import NotificationCenter -import XCTest +import Testing +@Suite @MainActor -final class NotificationManagerTests: XCTestCase { +final class NotificationManagerTests { var notificationManager: NotificationManager! private let clientProxy = ClientProxyMock(.init(userID: "@test:user.net")) private lazy var mockUserSession = UserSessionMock(.init(clientProxy: clientProxy)) @@ -27,7 +28,7 @@ final class NotificationManagerTests: XCTestCase { ServiceLocator.shared.settings } - override func setUp() { + init() { AppSettings.resetAllSettings() notificationCenter = UserNotificationCenterMock() notificationCenter.requestAuthorizationOptionsReturnValue = true @@ -39,80 +40,88 @@ final class NotificationManagerTests: XCTestCase { notificationManager.setUserSession(mockUserSession) } - override func tearDown() { + deinit { notificationCenter = nil notificationManager = nil } - func test_whenRegistered_pusherIsCalled() async { + @Test + func whenRegistered_pusherIsCalled() async { _ = await notificationManager.register(with: Data()) - XCTAssertTrue(clientProxy.setPusherWithCalled) + #expect(clientProxy.setPusherWithCalled) } - func test_whenRegisteredSuccess_completionSuccessIsCalled() async { + @Test + func whenRegisteredSuccess_completionSuccessIsCalled() async { let success = await notificationManager.register(with: Data()) - XCTAssertTrue(success) + #expect(success) } - func test_whenRegisteredAndPusherThrowsError_completionFalseIsCalled() async { + @Test + func whenRegisteredAndPusherThrowsError_completionFalseIsCalled() async { enum TestError: Error { case someError } clientProxy.setPusherWithThrowableError = TestError.someError let success = await notificationManager.register(with: Data()) - XCTAssertFalse(success) + #expect(!success) } - func test_whenRegistered_pusherIsCalledWithCorrectValues() async throws { + @Test + func whenRegistered_pusherIsCalledWithCorrectValues() async throws { let pushkeyData = Data("1234".utf8) _ = await notificationManager.register(with: pushkeyData) guard let configuration = clientProxy.setPusherWithReceivedInvocations.first else { - XCTFail("Invalid pusher configuration sent") + Issue.record("Invalid pusher configuration sent") return } - XCTAssertEqual(configuration.identifiers.pushkey, pushkeyData.base64EncodedString()) - XCTAssertEqual(configuration.identifiers.appId, appSettings.pusherAppID) - XCTAssertEqual(configuration.appDisplayName, "\(InfoPlistReader.main.bundleDisplayName) (iOS)") - XCTAssertEqual(configuration.deviceDisplayName, UIDevice.current.name) - XCTAssertNotNil(configuration.profileTag) - XCTAssertEqual(configuration.lang, Bundle.app.preferredLocalizations.first) + #expect(configuration.identifiers.pushkey == pushkeyData.base64EncodedString()) + #expect(configuration.identifiers.appId == appSettings.pusherAppID) + #expect(configuration.appDisplayName == "\(InfoPlistReader.main.bundleDisplayName) (iOS)") + #expect(configuration.deviceDisplayName == UIDevice.current.name) + #expect(configuration.profileTag != nil) + #expect(configuration.lang == Bundle.app.preferredLocalizations.first) guard case let .http(data) = configuration.kind else { - XCTFail("Http kind expected") + Issue.record("Http kind expected") return } - XCTAssertEqual(data.url, appSettings.pushGatewayNotifyEndpoint.absoluteString) - XCTAssertEqual(data.format, .eventIdOnly) + #expect(data.url == appSettings.pushGatewayNotifyEndpoint.absoluteString) + #expect(data.format == .eventIdOnly) let defaultPayload = APNSPayload(aps: APSInfo(mutableContent: 1, alert: APSAlert(locKey: "Notification", locArgs: [])), pusherNotificationClientIdentifier: nil) - XCTAssertEqual(data.defaultPayload, try defaultPayload.toJsonString()) + #expect(try data.defaultPayload == (defaultPayload.toJsonString())) } - func test_whenRegisteredAndPusherTagNotSetInSettings_tagGeneratedAndSavedInSettings() async { + @Test + func whenRegisteredAndPusherTagNotSetInSettings_tagGeneratedAndSavedInSettings() async { appSettings.pusherProfileTag = nil _ = await notificationManager.register(with: Data()) - XCTAssertNotNil(appSettings.pusherProfileTag) + #expect(appSettings.pusherProfileTag != nil) } - func test_whenRegisteredAndPusherTagIsSetInSettings_tagNotGenerated() async { + @Test + func whenRegisteredAndPusherTagIsSetInSettings_tagNotGenerated() async { appSettings.pusherProfileTag = "12345" _ = await notificationManager.register(with: Data()) - XCTAssertEqual(appSettings.pusherProfileTag, "12345") + #expect(appSettings.pusherProfileTag == "12345") } - func test_whenShowLocalNotification_notificationRequestGetsAdded() async throws { + @Test + func whenShowLocalNotification_notificationRequestGetsAdded() async throws { await notificationManager.showLocalNotification(with: "Title", subtitle: "Subtitle") - let request = try XCTUnwrap(notificationCenter.addReceivedRequest) - XCTAssertEqual(request.content.title, "Title") - XCTAssertEqual(request.content.subtitle, "Subtitle") + let request = try #require(notificationCenter.addReceivedRequest) + #expect(request.content.title == "Title") + #expect(request.content.subtitle == "Subtitle") } - func test_whenStart_notificationCategoriesAreSet() { + @Test + func whenStart_notificationCategoriesAreSet() { let replyAction = UNTextInputNotificationAction(identifier: NotificationConstants.Action.inlineReply, title: L10n.actionQuickReply, options: []) @@ -125,39 +134,42 @@ final class NotificationManagerTests: XCTestCase { actions: [], intentIdentifiers: [], options: []) - XCTAssertEqual(notificationCenter.setNotificationCategoriesReceivedCategories, [messageCategory, inviteCategory]) + #expect(notificationCenter.setNotificationCategoriesReceivedCategories == [messageCategory, inviteCategory]) } - func test_whenStart_delegateIsSet() throws { - let delegate = try XCTUnwrap(notificationCenter.delegate) - XCTAssertTrue(delegate.isEqual(notificationManager)) + @Test + func whenStart_delegateIsSet() throws { + let delegate = try #require(notificationCenter.delegate) + #expect(delegate.isEqual(notificationManager)) } - func test_whenStart_requestAuthorizationCalledWithCorrectParams() async { - let expectation = expectation(description: "requestAuthorization should be called") - notificationCenter.requestAuthorizationOptionsClosure = { _ in - expectation.fulfill() - return true + @Test + func whenStart_requestAuthorizationCalledWithCorrectParams() async { + await waitForConfirmation("requestAuthorization should be called", timeout: .seconds(10)) { confirm in + notificationCenter.requestAuthorizationOptionsClosure = { _ in + confirm() + return true + } + notificationManager.requestAuthorization() } - notificationManager.requestAuthorization() - await fulfillment(of: [expectation]) - XCTAssertEqual(notificationCenter.requestAuthorizationOptionsReceivedOptions, [.alert, .sound, .badge]) + #expect(notificationCenter.requestAuthorizationOptionsReceivedOptions == [.alert, .sound, .badge]) } - func test_whenStartAndAuthorizationGranted_delegateCalled() async { + @Test + func whenStartAndAuthorizationGranted_delegateCalled() async { authorizationStatusWasGranted = false notificationManager.delegate = self - let expectation: XCTestExpectation = expectation(description: "registerForRemoteNotifications delegate function should be called") - expectation.assertForOverFulfill = false - registerForRemoteNotificationsDelegateCalled = { - expectation.fulfill() + await waitForConfirmation("registerForRemoteNotifications delegate function should be called", timeout: .seconds(10)) { confirm in + registerForRemoteNotificationsDelegateCalled = { + confirm() + } + notificationManager.requestAuthorization() } - notificationManager.requestAuthorization() - await fulfillment(of: [expectation]) - XCTAssertTrue(authorizationStatusWasGranted) + #expect(authorizationStatusWasGranted) } - func test_whenStartAndAuthorizedAndNotificationDisabled_registerForRemoteNotificationsNotCalled() async throws { + @Test + func whenStartAndAuthorizedAndNotificationDisabled_registerForRemoteNotificationsNotCalled() async throws { appSettings.enableNotifications = false notificationCenter.authorizationStatusReturnValue = .authorized notificationManager.delegate = self @@ -165,65 +177,69 @@ final class NotificationManagerTests: XCTestCase { notificationManager.setUserSession(UserSessionMock(.init())) try await Task.sleep(for: .seconds(1)) - XCTAssertFalse(authorizationStatusWasGranted) + #expect(!authorizationStatusWasGranted) } - func test_whenStartAndAuthorized_registerForRemoteNotificationsCalled() async { + @Test + func whenStartAndAuthorized_registerForRemoteNotificationsCalled() async { appSettings.enableNotifications = true notificationCenter.authorizationStatusReturnValue = .authorized notificationManager.delegate = self - let expectation: XCTestExpectation = expectation(description: "registerForRemoteNotifications delegate function should be called") - expectation.assertForOverFulfill = false - registerForRemoteNotificationsDelegateCalled = { - expectation.fulfill() + await waitForConfirmation("registerForRemoteNotifications delegate function should be called", timeout: .seconds(10)) { confirm in + registerForRemoteNotificationsDelegateCalled = { + confirm() + } + notificationManager.setUserSession(UserSessionMock(.init())) } - notificationManager.setUserSession(UserSessionMock(.init())) - await fulfillment(of: [expectation]) - - XCTAssertTrue(authorizationStatusWasGranted) + #expect(authorizationStatusWasGranted) } - func test_whenWillPresentNotificationsDelegateNotSet_CorrectPresentationOptionsReturned() async throws { + @Test + func whenWillPresentNotificationsDelegateNotSet_CorrectPresentationOptionsReturned() async throws { let archiver = MockCoder(requiringSecureCoding: false) - let notification = try XCTUnwrap(UNNotification(coder: archiver)) + let notification = try #require(UNNotification(coder: archiver)) let options = await notificationManager.userNotificationCenter(UNUserNotificationCenter.current(), willPresent: notification) - XCTAssertEqual(options, [.badge, .sound, .list, .banner]) + #expect(options == [.badge, .sound, .list, .banner]) } - func test_whenWillPresentNotificationsDelegateSetAndNotificationsShoudNotBeDisplayed_CorrectPresentationOptionsReturned() async throws { + @Test + func whenWillPresentNotificationsDelegateSetAndNotificationsShoudNotBeDisplayed_CorrectPresentationOptionsReturned() async throws { shouldDisplayInAppNotificationReturnValue = false notificationManager.delegate = self let notification = try UNNotification.with(userInfo: [AnyHashable: Any]()) let options = await notificationManager.userNotificationCenter(UNUserNotificationCenter.current(), willPresent: notification) - XCTAssertEqual(options, []) + #expect(options == []) } - func test_whenWillPresentNotificationsDelegateSetAndNotificationsShoudBeDisplayed_CorrectPresentationOptionsReturned() async throws { + @Test + func whenWillPresentNotificationsDelegateSetAndNotificationsShoudBeDisplayed_CorrectPresentationOptionsReturned() async throws { shouldDisplayInAppNotificationReturnValue = true notificationManager.delegate = self let notification = try UNNotification.with(userInfo: [AnyHashable: Any]()) let options = await notificationManager.userNotificationCenter(UNUserNotificationCenter.current(), willPresent: notification) - XCTAssertEqual(options, [.badge, .sound, .list, .banner]) + #expect(options == [.badge, .sound, .list, .banner]) } - func test_whenNotificationCenterReceivedResponseInLineReply_delegateIsCalled() async throws { + @Test + func whenNotificationCenterReceivedResponseInLineReply_delegateIsCalled() async throws { handleInlineReplyDelegateCalled = false notificationManager.delegate = self let response = try UNTextInputNotificationResponse.with(userInfo: [AnyHashable: Any](), actionIdentifier: NotificationConstants.Action.inlineReply) await notificationManager.userNotificationCenter(UNUserNotificationCenter.current(), didReceive: response) - XCTAssertTrue(handleInlineReplyDelegateCalled) + #expect(handleInlineReplyDelegateCalled) } - func test_whenNotificationCenterReceivedResponseWithActionIdentifier_delegateIsCalled() async throws { + @Test + func whenNotificationCenterReceivedResponseWithActionIdentifier_delegateIsCalled() async throws { notificationTappedDelegateCalled = false notificationManager.delegate = self let response = try UNTextInputNotificationResponse.with(userInfo: [AnyHashable: Any](), actionIdentifier: UNNotificationDefaultActionIdentifier) await notificationManager.userNotificationCenter(UNUserNotificationCenter.current(), didReceive: response) - XCTAssertTrue(notificationTappedDelegateCalled) + #expect(notificationTappedDelegateCalled) } } diff --git a/UnitTests/Sources/NotificationSettingsEditScreenViewModelTests.swift b/UnitTests/Sources/NotificationSettingsEditScreenViewModelTests.swift index 07662bb2f..76fbf0224 100644 --- a/UnitTests/Sources/NotificationSettingsEditScreenViewModelTests.swift +++ b/UnitTests/Sources/NotificationSettingsEditScreenViewModelTests.swift @@ -8,10 +8,11 @@ @testable import ElementX import MatrixRustSDK -import XCTest +import Testing +@Suite @MainActor -class NotificationSettingsEditScreenViewModelTests: XCTestCase { +struct NotificationSettingsEditScreenViewModelTests { private var viewModel: NotificationSettingsEditScreenViewModelProtocol! private var notificationSettingsProxy: NotificationSettingsProxyMock! private var userSession: UserSessionMock! @@ -21,7 +22,7 @@ class NotificationSettingsEditScreenViewModelTests: XCTestCase { viewModel.context } - @MainActor override func setUpWithError() throws { + init() throws { notificationSettingsProxy = NotificationSettingsProxyMock(with: NotificationSettingsProxyMockConfiguration()) notificationSettingsProxy.getDefaultRoomNotificationModeIsEncryptedIsOneToOneReturnValue = .allMessages @@ -29,7 +30,8 @@ class NotificationSettingsEditScreenViewModelTests: XCTestCase { userSession = UserSessionMock(.init(clientProxy: clientProxy)) } - func testFetchSettings() async throws { + @Test + mutating func fetchSettings() async throws { notificationSettingsProxy.getDefaultRoomNotificationModeIsEncryptedIsOneToOneClosure = { isEncrypted, isOneToOne in switch (isEncrypted, isOneToOne) { case (_, true): @@ -49,21 +51,22 @@ class NotificationSettingsEditScreenViewModelTests: XCTestCase { // `getDefaultRoomNotificationModeIsEncryptedIsOneToOne` must have been called twice (for encrypted and unencrypted group chats) let invocations = notificationSettingsProxy.getDefaultRoomNotificationModeIsEncryptedIsOneToOneReceivedInvocations - XCTAssertEqual(invocations.count, 2) + #expect(invocations.count == 2) // First call for encrypted group chats - XCTAssertEqual(invocations[0].isEncrypted, true) - XCTAssertEqual(invocations[0].isOneToOne, false) + #expect(invocations[0].isEncrypted == true) + #expect(invocations[0].isOneToOne == false) // Second call for unencrypted group chats - XCTAssertEqual(invocations[1].isEncrypted, false) - XCTAssertEqual(invocations[1].isOneToOne, false) + #expect(invocations[1].isEncrypted == false) + #expect(invocations[1].isOneToOne == false) - XCTAssertEqual(context.viewState.defaultMode, .mentionsAndKeywordsOnly) - XCTAssertNil(context.viewState.bindings.alertInfo) - XCTAssertFalse(context.viewState.canPushEncryptedEvents) - XCTAssertNotNil(context.viewState.description(for: .mentionsAndKeywordsOnly)) + #expect(context.viewState.defaultMode == .mentionsAndKeywordsOnly) + #expect(context.viewState.bindings.alertInfo == nil) + #expect(!context.viewState.canPushEncryptedEvents) + #expect(context.viewState.description(for: .mentionsAndKeywordsOnly) != nil) } - func testFetchSettingsWithCanPushEncryptedEvents() async throws { + @Test + mutating func fetchSettingsWithCanPushEncryptedEvents() async throws { notificationSettingsProxy.getDefaultRoomNotificationModeIsEncryptedIsOneToOneClosure = { isEncrypted, isOneToOne in switch (isEncrypted, isOneToOne) { case (_, true): @@ -86,21 +89,22 @@ class NotificationSettingsEditScreenViewModelTests: XCTestCase { // `getDefaultRoomNotificationModeIsEncryptedIsOneToOne` must have been called twice (for encrypted and unencrypted group chats) let invocations = notificationSettingsProxy.getDefaultRoomNotificationModeIsEncryptedIsOneToOneReceivedInvocations - XCTAssertEqual(invocations.count, 2) + #expect(invocations.count == 2) // First call for encrypted group chats - XCTAssertEqual(invocations[0].isEncrypted, true) - XCTAssertEqual(invocations[0].isOneToOne, false) + #expect(invocations[0].isEncrypted == true) + #expect(invocations[0].isOneToOne == false) // Second call for unencrypted group chats - XCTAssertEqual(invocations[1].isEncrypted, false) - XCTAssertEqual(invocations[1].isOneToOne, false) + #expect(invocations[1].isEncrypted == false) + #expect(invocations[1].isOneToOne == false) - XCTAssertEqual(context.viewState.defaultMode, .mentionsAndKeywordsOnly) - XCTAssertNil(context.viewState.bindings.alertInfo) - XCTAssertTrue(context.viewState.canPushEncryptedEvents) - XCTAssertNil(context.viewState.description(for: .mentionsAndKeywordsOnly)) + #expect(context.viewState.defaultMode == .mentionsAndKeywordsOnly) + #expect(context.viewState.bindings.alertInfo == nil) + #expect(context.viewState.canPushEncryptedEvents) + #expect(context.viewState.description(for: .mentionsAndKeywordsOnly) == nil) } - func testSetModeAllMessages() async throws { + @Test + mutating func setModeAllMessages() async throws { notificationSettingsProxy.getDefaultRoomNotificationModeIsEncryptedIsOneToOneReturnValue = .mentionsAndKeywordsOnly viewModel = NotificationSettingsEditScreenViewModel(chatType: .groupChat, userSession: userSession) let deferred = deferFulfillment(viewModel.context.observe(\.viewState.defaultMode)) { $0 != nil } @@ -118,26 +122,27 @@ class NotificationSettingsEditScreenViewModelTests: XCTestCase { // `setDefaultRoomNotificationModeIsEncryptedIsOneToOneMode` must have been called twice (for encrypted and unencrypted group chats) let invocations = notificationSettingsProxy.setDefaultRoomNotificationModeIsEncryptedIsOneToOneModeReceivedInvocations - XCTAssertEqual(notificationSettingsProxy.setDefaultRoomNotificationModeIsEncryptedIsOneToOneModeCallsCount, 2) + #expect(notificationSettingsProxy.setDefaultRoomNotificationModeIsEncryptedIsOneToOneModeCallsCount == 2) // First call for encrypted group chats - XCTAssertEqual(invocations[0].isEncrypted, true) - XCTAssertEqual(invocations[0].isOneToOne, false) - XCTAssertEqual(invocations[0].mode, .allMessages) + #expect(invocations[0].isEncrypted == true) + #expect(invocations[0].isOneToOne == false) + #expect(invocations[0].mode == .allMessages) // Second call for unencrypted group chats - XCTAssertEqual(invocations[1].isEncrypted, false) - XCTAssertEqual(invocations[1].isOneToOne, false) - XCTAssertEqual(invocations[1].mode, .allMessages) + #expect(invocations[1].isEncrypted == false) + #expect(invocations[1].isOneToOne == false) + #expect(invocations[1].mode == .allMessages) deferredViewState = deferFulfillment(viewModel.context.observe(\.viewState.defaultMode), transitionValues: [.allMessages]) try await deferredViewState.fulfill() - XCTAssertEqual(context.viewState.defaultMode, .allMessages) - XCTAssertNil(context.viewState.bindings.alertInfo) + #expect(context.viewState.defaultMode == .allMessages) + #expect(context.viewState.bindings.alertInfo == nil) } - func testSetModeMentions() async throws { + @Test + mutating func setModeMentions() async throws { viewModel = NotificationSettingsEditScreenViewModel(chatType: .groupChat, userSession: userSession) let deferred = deferFulfillment(viewModel.context.observe(\.viewState.defaultMode)) { $0 != nil } @@ -155,26 +160,27 @@ class NotificationSettingsEditScreenViewModelTests: XCTestCase { // `setDefaultRoomNotificationModeIsEncryptedIsOneToOneMode` must have been called twice (for encrypted and unencrypted group chats) let invocations = notificationSettingsProxy.setDefaultRoomNotificationModeIsEncryptedIsOneToOneModeReceivedInvocations - XCTAssertEqual(notificationSettingsProxy.setDefaultRoomNotificationModeIsEncryptedIsOneToOneModeCallsCount, 2) + #expect(notificationSettingsProxy.setDefaultRoomNotificationModeIsEncryptedIsOneToOneModeCallsCount == 2) // First call for encrypted group chats - XCTAssertEqual(invocations[0].isEncrypted, true) - XCTAssertEqual(invocations[0].isOneToOne, false) - XCTAssertEqual(invocations[0].mode, .mentionsAndKeywordsOnly) + #expect(invocations[0].isEncrypted == true) + #expect(invocations[0].isOneToOne == false) + #expect(invocations[0].mode == .mentionsAndKeywordsOnly) // Second call for unencrypted group chats - XCTAssertEqual(invocations[1].isEncrypted, false) - XCTAssertEqual(invocations[1].isOneToOne, false) - XCTAssertEqual(invocations[1].mode, .mentionsAndKeywordsOnly) + #expect(invocations[1].isEncrypted == false) + #expect(invocations[1].isOneToOne == false) + #expect(invocations[1].mode == .mentionsAndKeywordsOnly) deferredViewState = deferFulfillment(viewModel.context.observe(\.viewState.defaultMode), transitionValues: [.mentionsAndKeywordsOnly]) try await deferredViewState.fulfill() - XCTAssertEqual(context.viewState.defaultMode, .mentionsAndKeywordsOnly) - XCTAssertNil(context.viewState.bindings.alertInfo) + #expect(context.viewState.defaultMode == .mentionsAndKeywordsOnly) + #expect(context.viewState.bindings.alertInfo == nil) } - func testSetModeDirectChats() async throws { + @Test + mutating func setModeDirectChats() async throws { notificationSettingsProxy.getDefaultRoomNotificationModeIsEncryptedIsOneToOneReturnValue = .mentionsAndKeywordsOnly // Initialize for direct chats viewModel = NotificationSettingsEditScreenViewModel(chatType: .oneToOneChat, userSession: userSession) @@ -194,18 +200,19 @@ class NotificationSettingsEditScreenViewModelTests: XCTestCase { // `setDefaultRoomNotificationModeIsEncryptedIsOneToOneMode` must have been called twice (for encrypted and unencrypted direct chats) let invocations = notificationSettingsProxy.setDefaultRoomNotificationModeIsEncryptedIsOneToOneModeReceivedInvocations - XCTAssertEqual(notificationSettingsProxy.setDefaultRoomNotificationModeIsEncryptedIsOneToOneModeCallsCount, 2) + #expect(notificationSettingsProxy.setDefaultRoomNotificationModeIsEncryptedIsOneToOneModeCallsCount == 2) // First call for encrypted direct chats - XCTAssertEqual(invocations[0].isEncrypted, true) - XCTAssertEqual(invocations[0].isOneToOne, true) - XCTAssertEqual(invocations[0].mode, .allMessages) + #expect(invocations[0].isEncrypted == true) + #expect(invocations[0].isOneToOne == true) + #expect(invocations[0].mode == .allMessages) // Second call for unencrypted direct chats - XCTAssertEqual(invocations[1].isEncrypted, false) - XCTAssertEqual(invocations[1].isOneToOne, true) - XCTAssertEqual(invocations[1].mode, .allMessages) + #expect(invocations[1].isEncrypted == false) + #expect(invocations[1].isOneToOne == true) + #expect(invocations[1].mode == .allMessages) } - func testSetModeFailure() async throws { + @Test + mutating func setModeFailure() async throws { notificationSettingsProxy.getDefaultRoomNotificationModeIsEncryptedIsOneToOneReturnValue = .mentionsAndKeywordsOnly notificationSettingsProxy.setDefaultRoomNotificationModeIsEncryptedIsOneToOneModeThrowableError = NotificationSettingsError.Generic(msg: "error") viewModel = NotificationSettingsEditScreenViewModel(chatType: .oneToOneChat, userSession: userSession) @@ -223,10 +230,11 @@ class NotificationSettingsEditScreenViewModelTests: XCTestCase { try await deferredViewState.fulfill() - XCTAssertNotNil(context.viewState.bindings.alertInfo) + #expect(context.viewState.bindings.alertInfo != nil) } - func testSelectRoom() async throws { + @Test + mutating func selectRoom() async throws { let roomID = "!roomidentifier:matrix.org" viewModel = NotificationSettingsEditScreenViewModel(chatType: .oneToOneChat, userSession: userSession) @@ -243,7 +251,7 @@ class NotificationSettingsEditScreenViewModelTests: XCTestCase { let expectedAction = NotificationSettingsEditScreenViewModelAction.requestRoomNotificationSettingsPresentation(roomID: roomID) guard case let .requestRoomNotificationSettingsPresentation(roomID: receivedRoomID) = sentAction, receivedRoomID == roomID else { - XCTFail("Expected action \(expectedAction), but was \(sentAction)") + Issue.record("Expected action \(expectedAction), but was \(sentAction)") return } } diff --git a/UnitTests/Sources/NotificationSettingsScreenViewModelTests.swift b/UnitTests/Sources/NotificationSettingsScreenViewModelTests.swift index 310c3d40d..db3704256 100644 --- a/UnitTests/Sources/NotificationSettingsScreenViewModelTests.swift +++ b/UnitTests/Sources/NotificationSettingsScreenViewModelTests.swift @@ -8,17 +8,18 @@ @testable import ElementX import MatrixRustSDK -import XCTest +import Testing +@Suite @MainActor -class NotificationSettingsScreenViewModelTests: XCTestCase { - private var viewModel: NotificationSettingsScreenViewModelProtocol! - private var context: NotificationSettingsScreenViewModelType.Context! - private var appSettings: AppSettings! - private var userNotificationCenter: UserNotificationCenterMock! - private var notificationSettingsProxy: NotificationSettingsProxyMock! +struct NotificationSettingsScreenViewModelTests { + private var viewModel: NotificationSettingsScreenViewModelProtocol + private var context: NotificationSettingsScreenViewModelType.Context + private var appSettings: AppSettings + private var userNotificationCenter: UserNotificationCenterMock + private var notificationSettingsProxy: NotificationSettingsProxyMock - @MainActor override func setUpWithError() throws { + init() throws { AppSettings.resetAllSettings() userNotificationCenter = UserNotificationCenterMock() @@ -36,19 +37,22 @@ class NotificationSettingsScreenViewModelTests: XCTestCase { context = viewModel.context } - func testEnableNotifications() { + @Test + func enableNotifications() { appSettings.enableNotifications = false context.send(viewAction: .changedEnableNotifications) - XCTAssertTrue(appSettings.enableNotifications) + #expect(appSettings.enableNotifications) } - func testDisableNotifications() { + @Test + func disableNotifications() { appSettings.enableNotifications = true context.send(viewAction: .changedEnableNotifications) - XCTAssertFalse(appSettings.enableNotifications) + #expect(!appSettings.enableNotifications) } - func testFetchSettings() async throws { + @Test + func fetchSettings() async throws { notificationSettingsProxy.getDefaultRoomNotificationModeIsEncryptedIsOneToOneClosure = { isEncrypted, isOneToOne in switch (isEncrypted, isOneToOne) { case (_, true): @@ -64,17 +68,18 @@ class NotificationSettingsScreenViewModelTests: XCTestCase { try await deferred.fulfill() - XCTAssertEqual(notificationSettingsProxy.getDefaultRoomNotificationModeIsEncryptedIsOneToOneCallsCount, 4) - XCTAssert(notificationSettingsProxy.isRoomMentionEnabledCalled) - XCTAssert(notificationSettingsProxy.isCallEnabledCalled) + #expect(notificationSettingsProxy.getDefaultRoomNotificationModeIsEncryptedIsOneToOneCallsCount == 4) + #expect(notificationSettingsProxy.isRoomMentionEnabledCalled) + #expect(notificationSettingsProxy.isCallEnabledCalled) - XCTAssertEqual(context.viewState.settings?.groupChatsMode, .mentionsAndKeywordsOnly) - XCTAssertEqual(context.viewState.settings?.directChatsMode, .allMessages) - XCTAssertEqual(context.viewState.settings?.inconsistentSettings, []) - XCTAssertNil(context.viewState.bindings.alertInfo) + #expect(context.viewState.settings?.groupChatsMode == .mentionsAndKeywordsOnly) + #expect(context.viewState.settings?.directChatsMode == .allMessages) + #expect(context.viewState.settings?.inconsistentSettings == []) + #expect(context.viewState.bindings.alertInfo == nil) } - func testInconsistentGroupChatsSettings() async throws { + @Test + func inconsistentGroupChatsSettings() async throws { notificationSettingsProxy.getDefaultRoomNotificationModeIsEncryptedIsOneToOneClosure = { isEncrypted, isOneToOne in switch (isEncrypted, isOneToOne) { case (true, false): @@ -92,11 +97,12 @@ class NotificationSettingsScreenViewModelTests: XCTestCase { try await deferred.fulfill() - XCTAssertEqual(context.viewState.settings?.groupChatsMode, .allMessages) - XCTAssertEqual(context.viewState.settings?.inconsistentSettings, [.init(chatType: .groupChat, isEncrypted: false)]) + #expect(context.viewState.settings?.groupChatsMode == .allMessages) + #expect(context.viewState.settings?.inconsistentSettings == [.init(chatType: .groupChat, isEncrypted: false)]) } - func testInconsistentDirectChatsSettings() async throws { + @Test + func inconsistentDirectChatsSettings() async throws { notificationSettingsProxy.getDefaultRoomNotificationModeIsEncryptedIsOneToOneClosure = { isEncrypted, isOneToOne in switch (isEncrypted, isOneToOne) { case (true, true): @@ -114,11 +120,12 @@ class NotificationSettingsScreenViewModelTests: XCTestCase { try await deferred.fulfill() - XCTAssertEqual(context.viewState.settings?.directChatsMode, .allMessages) - XCTAssertEqual(context.viewState.settings?.inconsistentSettings, [.init(chatType: .oneToOneChat, isEncrypted: false)]) + #expect(context.viewState.settings?.directChatsMode == .allMessages) + #expect(context.viewState.settings?.inconsistentSettings == [.init(chatType: .oneToOneChat, isEncrypted: false)]) } - func testFixInconsistentSettings() async throws { + @Test + func fixInconsistentSettings() async throws { // Initialize with a configuration mismatch where encrypted one-to-one chats is `.allMessages` and unencrypted one-to-one chats is `.mentionsAndKeywordsOnly` notificationSettingsProxy.getDefaultRoomNotificationModeIsEncryptedIsOneToOneClosure = { isEncrypted, isOneToOne in switch (isEncrypted, isOneToOne) { @@ -137,8 +144,8 @@ class NotificationSettingsScreenViewModelTests: XCTestCase { try await deferredSettings.fulfill() - XCTAssertEqual(context.viewState.settings?.directChatsMode, .allMessages) - XCTAssertEqual(context.viewState.settings?.inconsistentSettings, [.init(chatType: .oneToOneChat, isEncrypted: false)]) + #expect(context.viewState.settings?.directChatsMode == .allMessages) + #expect(context.viewState.settings?.inconsistentSettings == [.init(chatType: .oneToOneChat, isEncrypted: false)]) let deferredMismatch = deferFulfillment(viewModel.context.observe(\.viewState.fixingConfigurationMismatch), transitionValues: [false, true, false]) @@ -148,14 +155,15 @@ class NotificationSettingsScreenViewModelTests: XCTestCase { try await deferredMismatch.fulfill() // Ensure we only fix the invalid setting: unencrypted one-to-one chats should be set to `.allMessages` (to match encrypted one-to-one chats) - XCTAssertEqual(notificationSettingsProxy.setDefaultRoomNotificationModeIsEncryptedIsOneToOneModeCallsCount, 1) + #expect(notificationSettingsProxy.setDefaultRoomNotificationModeIsEncryptedIsOneToOneModeCallsCount == 1) let callArguments = notificationSettingsProxy.setDefaultRoomNotificationModeIsEncryptedIsOneToOneModeReceivedArguments - XCTAssertEqual(callArguments?.isEncrypted, false) - XCTAssertEqual(callArguments?.isOneToOne, true) - XCTAssertEqual(callArguments?.mode, .allMessages) + #expect(callArguments?.isEncrypted == false) + #expect(callArguments?.isOneToOne == true) + #expect(callArguments?.mode == .allMessages) } - func testFixAllInconsistentSettings() async throws { + @Test + func fixAllInconsistentSettings() async throws { // Initialize with a configuration mismatch where // - encrypted one-to-one chats is `.allMessages` and unencrypted one-to-one chats is `.mentionsAndKeywordsOnly` // - encrypted group chats is `.allMessages` and unencrypted group chats is `.mentionsAndKeywordsOnly` @@ -174,8 +182,8 @@ class NotificationSettingsScreenViewModelTests: XCTestCase { try await deferredSettings.fulfill() - XCTAssertEqual(context.viewState.settings?.directChatsMode, .allMessages) - XCTAssertEqual(context.viewState.settings?.inconsistentSettings, [.init(chatType: .groupChat, isEncrypted: false), .init(chatType: .oneToOneChat, isEncrypted: false)]) + #expect(context.viewState.settings?.directChatsMode == .allMessages) + #expect(context.viewState.settings?.inconsistentSettings == [.init(chatType: .groupChat, isEncrypted: false), .init(chatType: .oneToOneChat, isEncrypted: false)]) var deferredMismatch = deferFulfillment(viewModel.context.observe(\.viewState.fixingConfigurationMismatch)) { $0 } @@ -188,19 +196,20 @@ class NotificationSettingsScreenViewModelTests: XCTestCase { try await deferredMismatch.fulfill() // All problems should be fixed - XCTAssertEqual(notificationSettingsProxy.setDefaultRoomNotificationModeIsEncryptedIsOneToOneModeCallsCount, 2) + #expect(notificationSettingsProxy.setDefaultRoomNotificationModeIsEncryptedIsOneToOneModeCallsCount == 2) let callArguments = notificationSettingsProxy.setDefaultRoomNotificationModeIsEncryptedIsOneToOneModeReceivedInvocations // Ensure we fix the invalid unencrypted group chats setting (it should be set to `.allMessages` to match encrypted group chats) - XCTAssertEqual(callArguments[0].isEncrypted, false) - XCTAssertEqual(callArguments[0].isOneToOne, false) - XCTAssertEqual(callArguments[0].mode, .allMessages) + #expect(callArguments[0].isEncrypted == false) + #expect(callArguments[0].isOneToOne == false) + #expect(callArguments[0].mode == .allMessages) // Ensure we fix the invalid unencrypted one-to-one chats setting (it should be set to `.allMessages` to match encrypted one-to-one chats) - XCTAssertEqual(callArguments[1].isEncrypted, false) - XCTAssertEqual(callArguments[1].isOneToOne, true) - XCTAssertEqual(callArguments[1].mode, .allMessages) + #expect(callArguments[1].isEncrypted == false) + #expect(callArguments[1].isOneToOne == true) + #expect(callArguments[1].mode == .allMessages) } - func testToggleRoomMentionOff() async throws { + @Test + func toggleRoomMentionOff() async throws { notificationSettingsProxy.isRoomMentionEnabledReturnValue = true let deferredState = deferFulfillment(viewModel.context.observe(\.viewState.settings)) { $0 != nil } @@ -219,11 +228,12 @@ class NotificationSettingsScreenViewModelTests: XCTestCase { try await deferred.fulfill() - XCTAssert(notificationSettingsProxy.setRoomMentionEnabledEnabledCalled) - XCTAssertEqual(notificationSettingsProxy.setRoomMentionEnabledEnabledReceivedEnabled, false) + #expect(notificationSettingsProxy.setRoomMentionEnabledEnabledCalled) + #expect(notificationSettingsProxy.setRoomMentionEnabledEnabledReceivedEnabled == false) } - func testToggleRoomMentionOn() async throws { + @Test + func toggleRoomMentionOn() async throws { notificationSettingsProxy.isRoomMentionEnabledReturnValue = false let deferredInitialFetch = deferFulfillment(viewModel.context.observe(\.viewState.settings)) { $0 != nil } @@ -241,11 +251,12 @@ class NotificationSettingsScreenViewModelTests: XCTestCase { try await deferred.fulfill() - XCTAssert(notificationSettingsProxy.setRoomMentionEnabledEnabledCalled) - XCTAssertEqual(notificationSettingsProxy.setRoomMentionEnabledEnabledReceivedEnabled, true) + #expect(notificationSettingsProxy.setRoomMentionEnabledEnabledCalled) + #expect(notificationSettingsProxy.setRoomMentionEnabledEnabledReceivedEnabled == true) } - func testToggleRoomMentionFailure() async throws { + @Test + func toggleRoomMentionFailure() async throws { notificationSettingsProxy.setRoomMentionEnabledEnabledThrowableError = NotificationSettingsError.Generic(msg: "error") notificationSettingsProxy.isRoomMentionEnabledReturnValue = false @@ -267,10 +278,11 @@ class NotificationSettingsScreenViewModelTests: XCTestCase { try await deferred.fulfill() - XCTAssertNotNil(context.alertInfo) + #expect(context.alertInfo != nil) } - func testToggleCallsOff() async throws { + @Test + func toggleCallsOff() async throws { notificationSettingsProxy.isCallEnabledReturnValue = true let deferredInitialFetch = deferFulfillment(viewModel.context.observe(\.viewState.settings)) { $0 != nil } @@ -288,11 +300,12 @@ class NotificationSettingsScreenViewModelTests: XCTestCase { try await deferred.fulfill() - XCTAssert(notificationSettingsProxy.setCallEnabledEnabledCalled) - XCTAssertEqual(notificationSettingsProxy.setCallEnabledEnabledReceivedEnabled, false) + #expect(notificationSettingsProxy.setCallEnabledEnabledCalled) + #expect(notificationSettingsProxy.setCallEnabledEnabledReceivedEnabled == false) } - func testToggleCallsOn() async throws { + @Test + func toggleCallsOn() async throws { notificationSettingsProxy.isCallEnabledReturnValue = false let deferredInitialFetch = deferFulfillment(viewModel.context.observe(\.viewState.settings)) { $0 != nil } @@ -311,11 +324,12 @@ class NotificationSettingsScreenViewModelTests: XCTestCase { try await deferred.fulfill() - XCTAssert(notificationSettingsProxy.setCallEnabledEnabledCalled) - XCTAssertEqual(notificationSettingsProxy.setCallEnabledEnabledReceivedEnabled, true) + #expect(notificationSettingsProxy.setCallEnabledEnabledCalled) + #expect(notificationSettingsProxy.setCallEnabledEnabledReceivedEnabled == true) } - func testToggleCallsFailure() async throws { + @Test + func toggleCallsFailure() async throws { notificationSettingsProxy.setCallEnabledEnabledThrowableError = NotificationSettingsError.Generic(msg: "error") notificationSettingsProxy.isCallEnabledReturnValue = false @@ -337,10 +351,11 @@ class NotificationSettingsScreenViewModelTests: XCTestCase { try await deferred.fulfill() - XCTAssertNotNil(context.alertInfo) + #expect(context.alertInfo != nil) } - func testToggleInvitationsOff() async throws { + @Test + func toggleInvitationsOff() async throws { notificationSettingsProxy.isInviteForMeEnabledReturnValue = true let deferredInitialFetch = deferFulfillment(viewModel.context.observe(\.viewState.settings)) { $0 != nil } @@ -358,11 +373,12 @@ class NotificationSettingsScreenViewModelTests: XCTestCase { try await deferred.fulfill() - XCTAssert(notificationSettingsProxy.setInviteForMeEnabledEnabledCalled) - XCTAssertEqual(notificationSettingsProxy.setInviteForMeEnabledEnabledReceivedEnabled, false) + #expect(notificationSettingsProxy.setInviteForMeEnabledEnabledCalled) + #expect(notificationSettingsProxy.setInviteForMeEnabledEnabledReceivedEnabled == false) } - func testToggleInvitationsOn() async throws { + @Test + func toggleInvitationsOn() async throws { notificationSettingsProxy.isInviteForMeEnabledReturnValue = false let deferredInitialFetch = deferFulfillment(viewModel.context.observe(\.viewState.settings)) { $0 != nil } @@ -381,11 +397,12 @@ class NotificationSettingsScreenViewModelTests: XCTestCase { try await deferred.fulfill() - XCTAssert(notificationSettingsProxy.setInviteForMeEnabledEnabledCalled) - XCTAssertEqual(notificationSettingsProxy.setInviteForMeEnabledEnabledReceivedEnabled, true) + #expect(notificationSettingsProxy.setInviteForMeEnabledEnabledCalled) + #expect(notificationSettingsProxy.setInviteForMeEnabledEnabledReceivedEnabled == true) } - func testToggleInvitesFailure() async throws { + @Test + func toggleInvitesFailure() async throws { notificationSettingsProxy.setInviteForMeEnabledEnabledThrowableError = NotificationSettingsError.Generic(msg: "error") notificationSettingsProxy.isInviteForMeEnabledReturnValue = false @@ -407,6 +424,6 @@ class NotificationSettingsScreenViewModelTests: XCTestCase { try await deferred.fulfill() - XCTAssertNotNil(context.alertInfo) + #expect(context.alertInfo != nil) } } diff --git a/UnitTests/Sources/RoomDetailsEditScreenViewModelTests.swift b/UnitTests/Sources/RoomDetailsEditScreenViewModelTests.swift index b416fc1d8..fc7814c8f 100644 --- a/UnitTests/Sources/RoomDetailsEditScreenViewModelTests.swift +++ b/UnitTests/Sources/RoomDetailsEditScreenViewModelTests.swift @@ -8,10 +8,11 @@ @testable import ElementX import MatrixRustSDK -import XCTest +import Testing +@Suite @MainActor -class RoomDetailsEditScreenViewModelTests: XCTestCase { +struct RoomDetailsEditScreenViewModelTests { var viewModel: RoomDetailsEditScreenViewModel! var userIndicatorController: UserIndicatorControllerMock! @@ -20,65 +21,74 @@ class RoomDetailsEditScreenViewModelTests: XCTestCase { viewModel.context } - func testCannotSaveOnLanding() { + @Test + mutating func cannotSaveOnLanding() { setupViewModel(roomProxyConfiguration: .init(name: "Some room", members: [.mockMeAdmin])) - XCTAssertFalse(context.viewState.canSave) + #expect(!context.viewState.canSave) } - func testCanEdit() async throws { + @Test + mutating func canEdit() async throws { setupViewModel(roomProxyConfiguration: .init(name: "Some room", members: [.mockMeAdmin])) let deferred = deferFulfillment(context.$viewState) { $0.canEditName } try await deferred.fulfill() - XCTAssertTrue(context.viewState.canEditAvatar) - XCTAssertTrue(context.viewState.canEditName) - XCTAssertTrue(context.viewState.canEditTopic) + #expect(context.viewState.canEditAvatar) + #expect(context.viewState.canEditName) + #expect(context.viewState.canEditTopic) } - func testCannotEdit() { + @Test + mutating func cannotEdit() { setupViewModel(roomProxyConfiguration: .init(name: "Some room", members: [.mockMe])) - XCTAssertFalse(context.viewState.canEditAvatar) - XCTAssertFalse(context.viewState.canEditName) - XCTAssertFalse(context.viewState.canEditTopic) + #expect(!context.viewState.canEditAvatar) + #expect(!context.viewState.canEditName) + #expect(!context.viewState.canEditTopic) } - func testNameDidChange() { + @Test + mutating func nameDidChange() { setupViewModel(roomProxyConfiguration: .init(name: "Some room", members: [.mockMeAdmin])) context.name = "name" - XCTAssertTrue(context.viewState.nameDidChange) - XCTAssertTrue(context.viewState.canSave) + #expect(context.viewState.nameDidChange) + #expect(context.viewState.canSave) } - func testTopicDidChange() { + @Test + mutating func topicDidChange() { setupViewModel(roomProxyConfiguration: .init(name: "Some room", members: [.mockMeAdmin])) context.topic = "topic" - XCTAssertTrue(context.viewState.topicDidChange) - XCTAssertTrue(context.viewState.canSave) + #expect(context.viewState.topicDidChange) + #expect(context.viewState.canSave) } - func testAvatarDidChange() { + @Test + mutating func avatarDidChange() { setupViewModel(roomProxyConfiguration: .init(name: "Some room", avatarURL: .mockMXCAvatar, members: [.mockMeAdmin])) context.send(viewAction: .removeImage) - XCTAssertTrue(context.viewState.avatarDidChange) - XCTAssertTrue(context.viewState.canSave) + #expect(context.viewState.avatarDidChange) + #expect(context.viewState.canSave) } - func testEmptyNameCannotBeSaved() { + @Test + mutating func emptyNameCannotBeSaved() { setupViewModel(roomProxyConfiguration: .init(name: "Some room", members: [.mockMeAdmin])) context.name = "" - XCTAssertFalse(context.viewState.canSave) + #expect(!context.viewState.canSave) } - func testAvatarPickerShowsSheet() { + @Test + mutating func avatarPickerShowsSheet() { setupViewModel(roomProxyConfiguration: .init(name: "Some room", members: [.mockMeAdmin])) context.name = "name" - XCTAssertFalse(context.showMediaSheet) + #expect(!context.showMediaSheet) context.send(viewAction: .presentMediaSource) - XCTAssertTrue(context.showMediaSheet) + #expect(context.showMediaSheet) } - func testSaveTriggersViewModelAction() async throws { + @Test + mutating func saveTriggersViewModelAction() async throws { setupViewModel(roomProxyConfiguration: .init(name: "Some room", members: [.mockMeAdmin])) let deferred = deferFulfillment(viewModel.actions) { action in @@ -89,67 +99,72 @@ class RoomDetailsEditScreenViewModelTests: XCTestCase { context.send(viewAction: .save) let action = try await deferred.fulfill() - XCTAssertEqual(action, .saveFinished) + #expect(action == .saveFinished) } - func testCancelWithoutChanges() async throws { + @Test + mutating func cancelWithoutChanges() async throws { setupViewModel(roomProxyConfiguration: .init(name: "Some room", members: [.mockMeAdmin])) - XCTAssertFalse(context.viewState.canSave) - XCTAssertNil(context.alertInfo) + #expect(!context.viewState.canSave) + #expect(context.alertInfo == nil) let deferred = deferFulfillment(viewModel.actions) { $0 == .cancel } context.send(viewAction: .cancel) try await deferred.fulfill() - XCTAssertNil(context.alertInfo) + #expect(context.alertInfo == nil) } - func testCancelWithChangesAndDiscard() async throws { + @Test + mutating func cancelWithChangesAndDiscard() async throws { setupViewModel(roomProxyConfiguration: .init(name: "Some room", members: [.mockMeAdmin])) context.name = "name" - XCTAssertTrue(context.viewState.canSave) - XCTAssertNil(context.alertInfo) + #expect(context.viewState.canSave) + #expect(context.alertInfo == nil) context.send(viewAction: .cancel) - XCTAssertNotNil(context.alertInfo) + #expect(context.alertInfo != nil) let deferred = deferFulfillment(viewModel.actions) { $0 == .cancel } context.alertInfo?.secondaryButton?.action?() // Discard try await deferred.fulfill() } - func testCancelWithChangesAndSave() async throws { + @Test + mutating func cancelWithChangesAndSave() async throws { setupViewModel(roomProxyConfiguration: .init(name: "Some room", members: [.mockMeAdmin])) context.name = "name" - XCTAssertTrue(context.viewState.canSave) - XCTAssertNil(context.alertInfo) + #expect(context.viewState.canSave) + #expect(context.alertInfo == nil) context.send(viewAction: .cancel) - XCTAssertNotNil(context.alertInfo) + #expect(context.alertInfo != nil) let deferred = deferFulfillment(viewModel.actions) { $0 == .saveFinished } context.alertInfo?.primaryButton.action?() // Save try await deferred.fulfill() } - func testErrorShownOnFailedFetchOfMedia() async { + @Test + mutating func errorShownOnFailedFetchOfMedia() async { setupViewModel(roomProxyConfiguration: .init(name: "Some room", members: [.mockMeAdmin])) viewModel.didSelectMediaUrl(url: .picturesDirectory) try? await Task.sleep(for: .milliseconds(100)) - XCTAssertNotNil(context.alertInfo) + #expect(context.alertInfo != nil) } - func testDeleteAvatar() { + @Test + mutating func deleteAvatar() { setupViewModel(roomProxyConfiguration: .init(name: "Some room", avatarURL: .mockMXCAvatar, members: [.mockMeAdmin])) - XCTAssertNotNil(context.viewState.avatarURL) + #expect(context.viewState.avatarURL != nil) context.send(viewAction: .removeImage) - XCTAssertNil(context.viewState.avatarURL) + #expect(context.viewState.avatarURL == nil) } // MARK: - Private - private func setupViewModel(roomProxyConfiguration: JoinedRoomProxyMockConfiguration) { + private mutating func setupViewModel(roomProxyConfiguration: JoinedRoomProxyMockConfiguration) { userIndicatorController = UserIndicatorControllerMock.default viewModel = .init(roomProxy: JoinedRoomProxyMock(roomProxyConfiguration), userSession: UserSessionMock(.init()), diff --git a/UnitTests/Sources/RoomDetailsScreenViewModelTests.swift b/UnitTests/Sources/RoomDetailsScreenViewModelTests.swift index 63f002ffa..4e3d1fa58 100644 --- a/UnitTests/Sources/RoomDetailsScreenViewModelTests.swift +++ b/UnitTests/Sources/RoomDetailsScreenViewModelTests.swift @@ -11,10 +11,11 @@ import Combine @testable import ElementX import MatrixRustSDK import SwiftUI -import XCTest +import Testing +@Suite @MainActor -class RoomDetailsScreenViewModelTests: XCTestCase { +struct RoomDetailsScreenViewModelTests { var viewModel: RoomDetailsScreenViewModel! var roomProxyMock: JoinedRoomProxyMock! var notificationSettingsProxyMock: NotificationSettingsProxyMock! @@ -24,7 +25,7 @@ class RoomDetailsScreenViewModelTests: XCTestCase { var cancellables = Set() - override func setUp() { + init() { AppSettings.resetAllSettings() cancellables.removeAll() roomProxyMock = JoinedRoomProxyMock(.init(name: "Test")) @@ -38,7 +39,8 @@ class RoomDetailsScreenViewModelTests: XCTestCase { appSettings: ServiceLocator.shared.settings) } - func testLeaveRoomTappedWhenPublic() async throws { + @Test + mutating func leaveRoomTappedWhenPublic() async throws { let mockedMembers: [RoomMemberProxyMock] = [.mockBob, .mockAlice] roomProxyMock = JoinedRoomProxyMock(.init(name: "Test", members: mockedMembers, joinRule: .public)) viewModel = RoomDetailsScreenViewModel(roomProxy: roomProxyMock, @@ -53,11 +55,12 @@ class RoomDetailsScreenViewModelTests: XCTestCase { context.send(viewAction: .processTapLeave) try await deferred.fulfill() - XCTAssertEqual(context.viewState.bindings.leaveRoomAlertItem?.state, .public) - XCTAssertEqual(context.viewState.bindings.leaveRoomAlertItem?.subtitle, L10n.leaveRoomAlertSubtitle) + #expect(context.viewState.bindings.leaveRoomAlertItem?.state == .public) + #expect(context.viewState.bindings.leaveRoomAlertItem?.subtitle == L10n.leaveRoomAlertSubtitle) } - func testLeaveRoomTappedWhenRoomNotPublic() async throws { + @Test + mutating func leaveRoomTappedWhenRoomNotPublic() async throws { let mockedMembers: [RoomMemberProxyMock] = [.mockBob, .mockAlice] roomProxyMock = JoinedRoomProxyMock(.init(name: "Test", members: mockedMembers)) viewModel = RoomDetailsScreenViewModel(roomProxy: roomProxyMock, @@ -73,11 +76,12 @@ class RoomDetailsScreenViewModelTests: XCTestCase { try await deferred.fulfill() - XCTAssertEqual(context.viewState.bindings.leaveRoomAlertItem?.state, .private) - XCTAssertEqual(context.viewState.bindings.leaveRoomAlertItem?.subtitle, L10n.leaveRoomAlertPrivateSubtitle) + #expect(context.viewState.bindings.leaveRoomAlertItem?.state == .private) + #expect(context.viewState.bindings.leaveRoomAlertItem?.subtitle == L10n.leaveRoomAlertPrivateSubtitle) } - func testLeaveRoomTappedWithLessThanTwoMembers() { + @Test + mutating func leaveRoomTappedWithLessThanTwoMembers() { let mockedMembers: [RoomMemberProxyMock] = [.mockAlice] roomProxyMock = JoinedRoomProxyMock(.init(name: "Test", members: mockedMembers)) viewModel = RoomDetailsScreenViewModel(roomProxy: roomProxyMock, @@ -89,11 +93,12 @@ class RoomDetailsScreenViewModelTests: XCTestCase { appSettings: ServiceLocator.shared.settings) context.send(viewAction: .processTapLeave) - XCTAssertEqual(context.leaveRoomAlertItem?.state, .empty) - XCTAssertEqual(context.leaveRoomAlertItem?.subtitle, L10n.leaveRoomAlertEmptySubtitle) + #expect(context.leaveRoomAlertItem?.state == .empty) + #expect(context.leaveRoomAlertItem?.subtitle == L10n.leaveRoomAlertEmptySubtitle) } - func testLeaveRoomSuccess() async throws { + @Test + func leaveRoomSuccess() async throws { roomProxyMock.leaveRoomClosure = { .success(()) } @@ -111,24 +116,29 @@ class RoomDetailsScreenViewModelTests: XCTestCase { try await deferred.fulfill() - XCTAssertEqual(roomProxyMock.leaveRoomCallsCount, 1) + #expect(roomProxyMock.leaveRoomCallsCount == 1) } - func testLeaveRoomError() async { - let expectation = expectation(description: #function) - roomProxyMock.leaveRoomClosure = { - defer { - expectation.fulfill() + @Test + func leaveRoomError() async throws { + try await confirmation("leaveRoomError") { confirm in + roomProxyMock.leaveRoomClosure = { + defer { + confirm() + } + return .failure(.sdkError(ClientProxyMockError.generic)) } - return .failure(.sdkError(ClientProxyMockError.generic)) + + let deferred = deferFulfillment(context.observe(\.alertInfo)) { $0 != nil } + context.send(viewAction: .confirmLeave) + try await deferred.fulfill() } - context.send(viewAction: .confirmLeave) - await fulfillment(of: [expectation]) - XCTAssertEqual(roomProxyMock.leaveRoomCallsCount, 1) - XCTAssertNotNil(context.alertInfo) + + #expect(roomProxyMock.leaveRoomCallsCount == 1) } - func testInitialDMDetailsState() async throws { + @Test + mutating func initialDMDetailsState() async throws { let recipient = RoomMemberProxyMock.mockDan let mockedMembers: [RoomMemberProxyMock] = [.mockMe, recipient] roomProxyMock = JoinedRoomProxyMock(.init(name: "Test", isDirect: true, isEncrypted: true, members: mockedMembers)) @@ -144,10 +154,11 @@ class RoomDetailsScreenViewModelTests: XCTestCase { try await deferred.fulfill() - XCTAssertEqual(context.viewState.dmRecipientInfo?.member, RoomMemberDetails(withProxy: recipient)) + #expect(context.viewState.dmRecipientInfo?.member == RoomMemberDetails(withProxy: recipient)) } - func testIgnoreSuccess() async throws { + @Test + mutating func ignoreSuccess() async throws { let recipient = RoomMemberProxyMock.mockDan let mockedMembers: [RoomMemberProxyMock] = [.mockMe, recipient] @@ -164,7 +175,7 @@ class RoomDetailsScreenViewModelTests: XCTestCase { try await deferredRecipient.fulfill() - XCTAssertEqual(context.viewState.dmRecipientInfo?.member, RoomMemberDetails(withProxy: recipient)) + #expect(context.viewState.dmRecipientInfo?.member == RoomMemberDetails(withProxy: recipient)) let deferredProcessing = deferFulfillment(viewModel.context.observe(\.viewState.isProcessingIgnoreRequest), transitionValues: [false, true, false]) @@ -173,10 +184,11 @@ class RoomDetailsScreenViewModelTests: XCTestCase { try await deferredProcessing.fulfill() - XCTAssert(context.viewState.dmRecipientInfo?.member.isIgnored == true) + #expect(context.viewState.dmRecipientInfo?.member.isIgnored == true) } - func testIgnoreFailure() async throws { + @Test + mutating func ignoreFailure() async throws { let recipient = RoomMemberProxyMock.mockDan let mockedMembers: [RoomMemberProxyMock] = [.mockMe, recipient] let clientProxy = ClientProxyMock(.init()) @@ -194,7 +206,7 @@ class RoomDetailsScreenViewModelTests: XCTestCase { try await deferredRecipient.fulfill() - XCTAssertEqual(context.viewState.dmRecipientInfo?.member, RoomMemberDetails(withProxy: recipient)) + #expect(context.viewState.dmRecipientInfo?.member == RoomMemberDetails(withProxy: recipient)) let deferredProcessing = deferFulfillment(viewModel.context.observe(\.viewState.isProcessingIgnoreRequest), transitionValues: [false, true, false]) @@ -203,11 +215,12 @@ class RoomDetailsScreenViewModelTests: XCTestCase { try await deferredProcessing.fulfill() - XCTAssert(context.viewState.dmRecipientInfo?.member.isIgnored == false) - XCTAssertNotNil(context.alertInfo) + #expect(context.viewState.dmRecipientInfo?.member.isIgnored == false) + #expect(context.alertInfo != nil) } - func testUnignoreSuccess() async throws { + @Test + mutating func unignoreSuccess() async throws { let recipient = RoomMemberProxyMock.mockIgnored let mockedMembers: [RoomMemberProxyMock] = [.mockMe, recipient] roomProxyMock = JoinedRoomProxyMock(.init(name: "Test", isDirect: true, isEncrypted: true, members: mockedMembers)) @@ -223,7 +236,7 @@ class RoomDetailsScreenViewModelTests: XCTestCase { try await deferredRecipient.fulfill() - XCTAssertEqual(context.viewState.dmRecipientInfo?.member, RoomMemberDetails(withProxy: recipient)) + #expect(context.viewState.dmRecipientInfo?.member == RoomMemberDetails(withProxy: recipient)) let deferredProcessing = deferFulfillment(viewModel.context.observe(\.viewState.isProcessingIgnoreRequest), transitionValues: [false, true, false]) @@ -232,10 +245,11 @@ class RoomDetailsScreenViewModelTests: XCTestCase { try await deferredProcessing.fulfill() - XCTAssert(context.viewState.dmRecipientInfo?.member.isIgnored == false) + #expect(context.viewState.dmRecipientInfo?.member.isIgnored == false) } - func testUnignoreFailure() async throws { + @Test + mutating func unignoreFailure() async throws { let recipient = RoomMemberProxyMock.mockIgnored let mockedMembers: [RoomMemberProxyMock] = [.mockMe, recipient] let clientProxy = ClientProxyMock(.init()) @@ -253,7 +267,7 @@ class RoomDetailsScreenViewModelTests: XCTestCase { try await deferredRecipient.fulfill() - XCTAssertEqual(context.viewState.dmRecipientInfo?.member, RoomMemberDetails(withProxy: recipient)) + #expect(context.viewState.dmRecipientInfo?.member == RoomMemberDetails(withProxy: recipient)) let deferredProcessing = deferFulfillment(viewModel.context.observe(\.viewState.isProcessingIgnoreRequest), transitionValues: [false, true, false]) @@ -262,11 +276,12 @@ class RoomDetailsScreenViewModelTests: XCTestCase { try await deferredProcessing.fulfill() - XCTAssert(context.viewState.dmRecipientInfo?.member.isIgnored == true) - XCTAssertNotNil(context.alertInfo) + #expect(context.viewState.dmRecipientInfo?.member.isIgnored == true) + #expect(context.alertInfo != nil) } - func testCannotInvitePeople() async { + @Test + mutating func cannotInvitePeople() async { let mockedMembers: [RoomMemberProxyMock] = [.mockMe, .mockAlice] roomProxyMock = JoinedRoomProxyMock(.init(name: "Test", members: mockedMembers, @@ -282,10 +297,11 @@ class RoomDetailsScreenViewModelTests: XCTestCase { _ = await context.observe(\.viewState).debounce(for: .milliseconds(100)).first() - XCTAssertFalse(context.viewState.canInviteUsers) + #expect(!context.viewState.canInviteUsers) } - func testInvitePeople() async { + @Test + mutating func invitePeople() async { let mockedMembers: [RoomMemberProxyMock] = [.mockMe, .mockBob, .mockAlice] roomProxyMock = JoinedRoomProxyMock(.init(name: "Test", members: mockedMembers, joinRule: .public)) viewModel = RoomDetailsScreenViewModel(roomProxy: roomProxyMock, @@ -298,7 +314,7 @@ class RoomDetailsScreenViewModelTests: XCTestCase { _ = await context.observe(\.viewState).debounce(for: .milliseconds(100)).first() - XCTAssertTrue(context.viewState.canInviteUsers) + #expect(context.viewState.canInviteUsers) var callbackCorrectlyCalled = false viewModel.actions @@ -314,10 +330,11 @@ class RoomDetailsScreenViewModelTests: XCTestCase { context.send(viewAction: .processTapInvite) await Task.yield() - XCTAssertTrue(callbackCorrectlyCalled) + #expect(callbackCorrectlyCalled) } - func testCanEditAvatar() async { + @Test + mutating func canEditAvatar() async { let mockedMembers: [RoomMemberProxyMock] = [.mockMe, .mockBob, .mockAlice] let configuration = JoinedRoomProxyMockConfiguration(name: "Test", @@ -349,13 +366,14 @@ class RoomDetailsScreenViewModelTests: XCTestCase { _ = await context.observe(\.viewState).debounce(for: .milliseconds(100)).first() - XCTAssertTrue(context.viewState.canEditRoomAvatar) - XCTAssertFalse(context.viewState.canEditRoomName) - XCTAssertFalse(context.viewState.canEditRoomTopic) - XCTAssertTrue(context.viewState.canEditBaseInfo) + #expect(context.viewState.canEditRoomAvatar) + #expect(!context.viewState.canEditRoomName) + #expect(!context.viewState.canEditRoomTopic) + #expect(context.viewState.canEditBaseInfo) } - func testCanEditName() async { + @Test + mutating func canEditName() async { let mockedMembers: [RoomMemberProxyMock] = [.mockMe, .mockBob, .mockAlice] let configuration = JoinedRoomProxyMockConfiguration(name: "Test", @@ -387,13 +405,14 @@ class RoomDetailsScreenViewModelTests: XCTestCase { _ = await context.observe(\.viewState).debounce(for: .milliseconds(100)).first() - XCTAssertFalse(context.viewState.canEditRoomAvatar) - XCTAssertTrue(context.viewState.canEditRoomName) - XCTAssertFalse(context.viewState.canEditRoomTopic) - XCTAssertTrue(context.viewState.canEditBaseInfo) + #expect(!context.viewState.canEditRoomAvatar) + #expect(context.viewState.canEditRoomName) + #expect(!context.viewState.canEditRoomTopic) + #expect(context.viewState.canEditBaseInfo) } - func testCanEditTopic() async { + @Test + mutating func canEditTopic() async { let mockedMembers: [RoomMemberProxyMock] = [.mockMe, .mockBob, .mockAlice] let configuration = JoinedRoomProxyMockConfiguration(name: "Test", @@ -425,13 +444,14 @@ class RoomDetailsScreenViewModelTests: XCTestCase { _ = await context.observe(\.viewState).debounce(for: .milliseconds(100)).first() - XCTAssertFalse(context.viewState.canEditRoomAvatar) - XCTAssertFalse(context.viewState.canEditRoomName) - XCTAssertTrue(context.viewState.canEditRoomTopic) - XCTAssertTrue(context.viewState.canEditBaseInfo) + #expect(!context.viewState.canEditRoomAvatar) + #expect(!context.viewState.canEditRoomName) + #expect(context.viewState.canEditRoomTopic) + #expect(context.viewState.canEditBaseInfo) } - func testCannotEditRoom() async { + @Test + mutating func cannotEditRoom() async { let mockedMembers: [RoomMemberProxyMock] = [.mockMe, .mockBob, .mockAlice] roomProxyMock = JoinedRoomProxyMock(.init(name: "Test", isDirect: false, members: mockedMembers)) viewModel = RoomDetailsScreenViewModel(roomProxy: roomProxyMock, @@ -444,13 +464,14 @@ class RoomDetailsScreenViewModelTests: XCTestCase { _ = await context.observe(\.viewState).debounce(for: .milliseconds(100)).first() - XCTAssertFalse(context.viewState.canEditRoomAvatar) - XCTAssertFalse(context.viewState.canEditRoomName) - XCTAssertFalse(context.viewState.canEditRoomTopic) - XCTAssertFalse(context.viewState.canEditBaseInfo) + #expect(!context.viewState.canEditRoomAvatar) + #expect(!context.viewState.canEditRoomName) + #expect(!context.viewState.canEditRoomTopic) + #expect(!context.viewState.canEditBaseInfo) } - func testCannotEditDirectRoom() async { + @Test + mutating func cannotEditDirectRoom() async { let mockedMembers: [RoomMemberProxyMock] = [.mockMeAdmin, .mockBob, .mockAlice] roomProxyMock = JoinedRoomProxyMock(.init(name: "Test", isDirect: true, members: mockedMembers)) viewModel = RoomDetailsScreenViewModel(roomProxy: roomProxyMock, @@ -463,12 +484,13 @@ class RoomDetailsScreenViewModelTests: XCTestCase { _ = await context.observe(\.viewState).debounce(for: .milliseconds(100)).first() - XCTAssertFalse(context.viewState.canEditBaseInfo) + #expect(!context.viewState.canEditBaseInfo) } // MARK: - Notifications - func testNotificationLoadingSettingsFailure() async throws { + @Test + mutating func notificationLoadingSettingsFailure() async throws { notificationSettingsProxyMock.getNotificationSettingsRoomIdIsEncryptedIsOneToOneThrowableError = NotificationSettingsError.Generic(msg: "error") viewModel = RoomDetailsScreenViewModel(roomProxy: roomProxyMock, userSession: UserSessionMock(.init()), @@ -491,12 +513,13 @@ class RoomDetailsScreenViewModelTests: XCTestCase { let expectedAlertInfo = AlertInfo(id: RoomDetailsScreenErrorType.alert, title: L10n.commonError, message: L10n.screenRoomDetailsErrorLoadingNotificationSettings) - XCTAssertEqual(context.viewState.bindings.alertInfo?.id, expectedAlertInfo.id) - XCTAssertEqual(context.viewState.bindings.alertInfo?.title, expectedAlertInfo.title) - XCTAssertEqual(context.viewState.bindings.alertInfo?.message, expectedAlertInfo.message) + #expect(context.viewState.bindings.alertInfo?.id == expectedAlertInfo.id) + #expect(context.viewState.bindings.alertInfo?.title == expectedAlertInfo.title) + #expect(context.viewState.bindings.alertInfo?.message == expectedAlertInfo.message) } - func testNotificationDefaultMode() async throws { + @Test + func notificationDefaultMode() async throws { notificationSettingsProxyMock.getNotificationSettingsRoomIdIsEncryptedIsOneToOneReturnValue = RoomNotificationSettingsProxyMock(with: .init(mode: .allMessages, isDefault: true)) let deferred = deferFulfillment(context.observe(\.viewState.notificationSettingsState)) { $0.isLoaded } @@ -504,10 +527,11 @@ class RoomDetailsScreenViewModelTests: XCTestCase { notificationSettingsProxyMock.callbacks.send(.settingsDidChange) try await deferred.fulfill() - XCTAssertEqual(context.viewState.notificationSettingsState.label, "Default") + #expect(context.viewState.notificationSettingsState.label == "Default") } - func testNotificationCustomMode() async throws { + @Test + func notificationCustomMode() async throws { notificationSettingsProxyMock.getNotificationSettingsRoomIdIsEncryptedIsOneToOneReturnValue = RoomNotificationSettingsProxyMock(with: .init(mode: .allMessages, isDefault: false)) let deferred = deferFulfillment(context.observe(\.viewState.notificationSettingsState)) { $0.isCustom } @@ -515,10 +539,11 @@ class RoomDetailsScreenViewModelTests: XCTestCase { notificationSettingsProxyMock.callbacks.send(.settingsDidChange) try await deferred.fulfill() - XCTAssertEqual(context.viewState.notificationSettingsState.label, "Custom") + #expect(context.viewState.notificationSettingsState.label == "Custom") } - func testNotificationRoomMuted() async throws { + @Test + func notificationRoomMuted() async throws { notificationSettingsProxyMock.getNotificationSettingsRoomIdIsEncryptedIsOneToOneReturnValue = RoomNotificationSettingsProxyMock(with: .init(mode: .mute, isDefault: false)) let deferred = deferFulfillment(context.observe(\.viewState.notificationSettingsState)) { $0.isLoaded } @@ -528,11 +553,12 @@ class RoomDetailsScreenViewModelTests: XCTestCase { _ = await context.observe(\.viewState).debounce(for: .milliseconds(100)).first() - XCTAssertEqual(context.viewState.notificationShortcutButtonTitle, L10n.commonUnmute) - XCTAssertEqual(context.viewState.notificationShortcutButtonIcon, \.notificationsOff) + #expect(context.viewState.notificationShortcutButtonTitle == L10n.commonUnmute) + #expect(context.viewState.notificationShortcutButtonIcon == \.notificationsOff) } - func testNotificationRoomNotMuted() async throws { + @Test + func notificationRoomNotMuted() async throws { notificationSettingsProxyMock.getNotificationSettingsRoomIdIsEncryptedIsOneToOneReturnValue = RoomNotificationSettingsProxyMock(with: .init(mode: .mentionsAndKeywordsOnly, isDefault: false)) let deferred = deferFulfillment(context.observe(\.viewState.notificationSettingsState)) { $0.isLoaded } @@ -540,72 +566,81 @@ class RoomDetailsScreenViewModelTests: XCTestCase { notificationSettingsProxyMock.callbacks.send(.settingsDidChange) try await deferred.fulfill() - XCTAssertEqual(context.viewState.notificationShortcutButtonTitle, L10n.commonMute) - XCTAssertEqual(context.viewState.notificationShortcutButtonIcon, \.notifications) + #expect(context.viewState.notificationShortcutButtonTitle == L10n.commonMute) + #expect(context.viewState.notificationShortcutButtonIcon == \.notifications) } - func testUnmuteTappedFailure() async throws { - try await testNotificationRoomMuted() + @Test + func unmuteTappedFailure() async throws { + try await notificationRoomMuted() - let expectation = expectation(description: #function) - notificationSettingsProxyMock.unmuteRoomRoomIdIsEncryptedIsOneToOneClosure = { _, _, _ in - defer { - expectation.fulfill() + try await confirmation("unmuteTappedFailure") { confirm in + notificationSettingsProxyMock.unmuteRoomRoomIdIsEncryptedIsOneToOneClosure = { _, _, _ in + defer { + confirm() + } + throw NotificationSettingsError.Generic(msg: "unmute error") } - throw NotificationSettingsError.Generic(msg: "unmute error") + context.send(viewAction: .processToggleMuteNotifications) + try await deferFulfillment(context.observe(\.alertInfo)) { $0 != nil }.fulfill() } - context.send(viewAction: .processToggleMuteNotifications) - await fulfillment(of: [expectation]) - XCTAssertFalse(context.viewState.isProcessingMuteToggleAction) - XCTAssertEqual(context.viewState.notificationShortcutButtonTitle, L10n.commonUnmute) + #expect(!context.viewState.isProcessingMuteToggleAction) + #expect(context.viewState.notificationShortcutButtonTitle == L10n.commonUnmute) let expectedAlertInfo = AlertInfo(id: RoomDetailsScreenErrorType.alert, title: L10n.commonError, message: L10n.screenRoomDetailsErrorUnmuting) - XCTAssertEqual(context.viewState.bindings.alertInfo?.id, expectedAlertInfo.id) - XCTAssertEqual(context.viewState.bindings.alertInfo?.title, expectedAlertInfo.title) - XCTAssertEqual(context.viewState.bindings.alertInfo?.message, expectedAlertInfo.message) + #expect(context.viewState.bindings.alertInfo?.id == expectedAlertInfo.id) + #expect(context.viewState.bindings.alertInfo?.title == expectedAlertInfo.title) + #expect(context.viewState.bindings.alertInfo?.message == expectedAlertInfo.message) } - func testMuteTappedFailure() async throws { - try await testNotificationRoomNotMuted() + @Test + func muteTappedFailure() async throws { + try await notificationRoomNotMuted() - let expectation = expectation(description: #function) - notificationSettingsProxyMock.setNotificationModeRoomIdModeClosure = { _, _ in - defer { - expectation.fulfill() + try await confirmation("muteTappedFailure") { confirm in + notificationSettingsProxyMock.setNotificationModeRoomIdModeClosure = { _, _ in + defer { + confirm() + } + throw NotificationSettingsError.Generic(msg: "mute error") } - throw NotificationSettingsError.Generic(msg: "mute error") + + let deferred = deferFulfillment(context.observe(\.alertInfo)) { $0 != nil } + context.send(viewAction: .processToggleMuteNotifications) + try await deferred.fulfill() } - context.send(viewAction: .processToggleMuteNotifications) - await fulfillment(of: [expectation]) - XCTAssertFalse(context.viewState.isProcessingMuteToggleAction) - XCTAssertEqual(context.viewState.notificationShortcutButtonTitle, L10n.commonMute) + #expect(!context.viewState.isProcessingMuteToggleAction) + #expect(context.viewState.notificationShortcutButtonTitle == L10n.commonMute) let expectedAlertInfo = AlertInfo(id: RoomDetailsScreenErrorType.alert, title: L10n.commonError, message: L10n.screenRoomDetailsErrorMuting) - XCTAssertEqual(context.viewState.bindings.alertInfo?.id, expectedAlertInfo.id) - XCTAssertEqual(context.viewState.bindings.alertInfo?.title, expectedAlertInfo.title) - XCTAssertEqual(context.viewState.bindings.alertInfo?.message, expectedAlertInfo.message) + #expect(context.viewState.bindings.alertInfo?.id == expectedAlertInfo.id) + #expect(context.viewState.bindings.alertInfo?.title == expectedAlertInfo.title) + #expect(context.viewState.bindings.alertInfo?.message == expectedAlertInfo.message) } - func testMuteTapped() async throws { - try await testNotificationRoomNotMuted() + @Test + func muteTapped() async throws { + try await notificationRoomNotMuted() - let expectation = expectation(description: #function) - notificationSettingsProxyMock.setNotificationModeRoomIdModeClosure = { [weak notificationSettingsProxyMock] _, mode in - notificationSettingsProxyMock?.getNotificationSettingsRoomIdIsEncryptedIsOneToOneReturnValue = RoomNotificationSettingsProxyMock(with: .init(mode: mode, isDefault: false)) - expectation.fulfill() + try await confirmation("muteTapped") { confirm in + notificationSettingsProxyMock.setNotificationModeRoomIdModeClosure = { [weak notificationSettingsProxyMock] _, mode in + notificationSettingsProxyMock?.getNotificationSettingsRoomIdIsEncryptedIsOneToOneReturnValue = RoomNotificationSettingsProxyMock(with: .init(mode: mode, isDefault: false)) + confirm() + } + + let deferred = deferFulfillment(context.observe(\.viewState.isProcessingMuteToggleAction), + transitionValues: [false, true, false]) + context.send(viewAction: .processToggleMuteNotifications) + try await deferred.fulfill() } - context.send(viewAction: .processToggleMuteNotifications) - await fulfillment(of: [expectation]) - - XCTAssertFalse(context.viewState.isProcessingMuteToggleAction) let deferred = deferFulfillment(context.observe(\.viewState.notificationSettingsState)) { state in switch state { @@ -618,25 +653,28 @@ class RoomDetailsScreenViewModelTests: XCTestCase { try await deferred.fulfill() if case .loaded(let newNotificationSettingsState) = viewModel.state.notificationSettingsState { - XCTAssertFalse(newNotificationSettingsState.isDefault) - XCTAssertEqual(newNotificationSettingsState.mode, .mute) + #expect(!newNotificationSettingsState.isDefault) + #expect(newNotificationSettingsState.mode == .mute) } else { - XCTFail("invalid state") + Issue.record("invalid state") } } - func testUnmuteTapped() async throws { - try await testNotificationRoomMuted() + @Test + func unmuteTapped() async throws { + try await notificationRoomMuted() - let expectation = expectation(description: #function) - notificationSettingsProxyMock.unmuteRoomRoomIdIsEncryptedIsOneToOneClosure = { [weak notificationSettingsProxyMock] _, _, _ in - notificationSettingsProxyMock?.getNotificationSettingsRoomIdIsEncryptedIsOneToOneReturnValue = RoomNotificationSettingsProxyMock(with: .init(mode: .allMessages, isDefault: false)) - expectation.fulfill() + try await confirmation("unmuteTapped") { confirm in + notificationSettingsProxyMock.unmuteRoomRoomIdIsEncryptedIsOneToOneClosure = { [weak notificationSettingsProxyMock] _, _, _ in + notificationSettingsProxyMock?.getNotificationSettingsRoomIdIsEncryptedIsOneToOneReturnValue = RoomNotificationSettingsProxyMock(with: .init(mode: .allMessages, isDefault: false)) + confirm() + } + + let deferred = deferFulfillment(context.observe(\.viewState.isProcessingMuteToggleAction), + transitionValues: [false, true, false]) + context.send(viewAction: .processToggleMuteNotifications) + try await deferred.fulfill() } - context.send(viewAction: .processToggleMuteNotifications) - await fulfillment(of: [expectation]) - - XCTAssertFalse(context.viewState.isProcessingMuteToggleAction) let deferred = deferFulfillment(context.observe(\.viewState.notificationSettingsState)) { state in switch state { @@ -649,16 +687,17 @@ class RoomDetailsScreenViewModelTests: XCTestCase { try await deferred.fulfill() if case .loaded(let newNotificationSettingsState) = viewModel.state.notificationSettingsState { - XCTAssertFalse(newNotificationSettingsState.isDefault) - XCTAssertEqual(newNotificationSettingsState.mode, .allMessages) + #expect(!newNotificationSettingsState.isDefault) + #expect(newNotificationSettingsState.mode == .allMessages) } else { - XCTFail("invalid state") + Issue.record("invalid state") } } // MARK: - Knock Requests - func testKnockRequestsCounter() async throws { + @Test + mutating func knockRequestsCounter() async throws { ServiceLocator.shared.settings.knockingEnabled = true let mockedRequests: [KnockRequestProxyMock] = [.init(), .init()] roomProxyMock = JoinedRoomProxyMock(.init(name: "Test", isDirect: false, knockRequestsState: .loaded(mockedRequests), joinRule: .knock)) @@ -680,7 +719,8 @@ class RoomDetailsScreenViewModelTests: XCTestCase { try await deferredAction.fulfill() } - func testKnockRequestsCounterIsLoading() async throws { + @Test + mutating func knockRequestsCounterIsLoading() async throws { ServiceLocator.shared.settings.knockingEnabled = true roomProxyMock = JoinedRoomProxyMock(.init(name: "Test", isDirect: false, knockRequestsState: .loading, joinRule: .knock)) viewModel = RoomDetailsScreenViewModel(roomProxy: roomProxyMock, @@ -698,7 +738,8 @@ class RoomDetailsScreenViewModelTests: XCTestCase { try await deferred.fulfill() } - func testKnockRequestsCounterIsNotShownIfNoPermissions() async throws { + @Test + mutating func knockRequestsCounterIsNotShownIfNoPermissions() async throws { ServiceLocator.shared.settings.knockingEnabled = true let mockedRequests: [KnockRequestProxyMock] = [.init(), .init()] roomProxyMock = JoinedRoomProxyMock(.init(name: "Test", @@ -724,7 +765,8 @@ class RoomDetailsScreenViewModelTests: XCTestCase { try await deferred.fulfill() } - func testKnockRequestsCounterIsNotShownIfDM() async throws { + @Test + mutating func knockRequestsCounterIsNotShownIfDM() async throws { ServiceLocator.shared.settings.knockingEnabled = true let mockedRequests: [KnockRequestProxyMock] = [.init(), .init()] let mockedMembers: [RoomMemberProxyMock] = [.mockMe, .mockAlice] @@ -749,7 +791,8 @@ class RoomDetailsScreenViewModelTests: XCTestCase { // MARK: - History Sharing - func testHistorySharingPillDoesNotAppearIfFeatureFlagNotSet() async throws { + @Test + mutating func historySharingPillDoesNotAppearIfFeatureFlagNotSet() async throws { ServiceLocator.shared.settings.enableKeyShareOnInvite = false let configuration = JoinedRoomProxyMockConfiguration(historyVisibility: .shared) @@ -766,14 +809,15 @@ class RoomDetailsScreenViewModelTests: XCTestCase { appSettings: ServiceLocator.shared.settings) let deferredInvisible = deferFailure(context.observe(\.viewState), - timeout: 1, + timeout: .seconds(1), message: "The pill should not be shown as the feature flag is not set") { state in state.details.historySharingState != nil } try await deferredInvisible.fulfill() } - func testHistorySharingPillDisplayedIfHistoryVisibilityShared() async throws { + @Test + mutating func historySharingPillDisplayedIfHistoryVisibilityShared() async throws { ServiceLocator.shared.settings.enableKeyShareOnInvite = true let configuration = JoinedRoomProxyMockConfiguration(historyVisibility: .shared) diff --git a/UnitTests/Sources/RoomFlowCoordinatorTests.swift b/UnitTests/Sources/RoomFlowCoordinatorTests.swift index 341dcf991..135c04946 100644 --- a/UnitTests/Sources/RoomFlowCoordinatorTests.swift +++ b/UnitTests/Sources/RoomFlowCoordinatorTests.swift @@ -9,161 +9,171 @@ import Combine @testable import ElementX import MatrixRustSDKMocks -import XCTest +import Testing +@Suite @MainActor -class RoomFlowCoordinatorTests: XCTestCase { +final class RoomFlowCoordinatorTests { var clientProxy: ClientProxyMock! var timelineControllerFactory: TimelineControllerFactoryMock! var roomFlowCoordinator: RoomFlowCoordinator! var navigationStackCoordinator: NavigationStackCoordinator! var cancellables = Set() - override func tearDown() { + deinit { AppSettings.resetAllSettings() } - func testRoomPresentation() async throws { + @Test + func roomPresentation() async throws { setupRoomFlowCoordinator() try await process(route: .room(roomID: "1", via: [])) - XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) + #expect(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) try await clearRoute(expectedActions: [.finished]) - XCTAssertNil(navigationStackCoordinator.rootCoordinator) + #expect(navigationStackCoordinator.rootCoordinator == nil) } - func testRoomDetailsPresentation() async throws { + @Test + func roomDetailsPresentation() async throws { setupRoomFlowCoordinator() try await process(route: .roomDetails(roomID: "1")) - XCTAssert(navigationStackCoordinator.rootCoordinator is RoomDetailsScreenCoordinator) + #expect(navigationStackCoordinator.rootCoordinator is RoomDetailsScreenCoordinator) try await clearRoute(expectedActions: [.finished]) - XCTAssertNil(navigationStackCoordinator.rootCoordinator) + #expect(navigationStackCoordinator.rootCoordinator == nil) } - func testNoOp() async throws { + @Test + func noOp() async throws { setupRoomFlowCoordinator() try await process(route: .roomDetails(roomID: "1")) - XCTAssert(navigationStackCoordinator.rootCoordinator is RoomDetailsScreenCoordinator) + #expect(navigationStackCoordinator.rootCoordinator is RoomDetailsScreenCoordinator) let detailsCoordinator = navigationStackCoordinator.rootCoordinator roomFlowCoordinator.handleAppRoute(.roomDetails(roomID: "1"), animated: true) await Task.yield() - XCTAssert(navigationStackCoordinator.rootCoordinator is RoomDetailsScreenCoordinator) - XCTAssert(navigationStackCoordinator.rootCoordinator === detailsCoordinator) + #expect(navigationStackCoordinator.rootCoordinator is RoomDetailsScreenCoordinator) + #expect(navigationStackCoordinator.rootCoordinator === detailsCoordinator) } - func testPushDetails() async throws { + @Test + func pushDetails() async throws { setupRoomFlowCoordinator() try await process(route: .room(roomID: "1", via: [])) - XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) - XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 0) + #expect(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) + #expect(navigationStackCoordinator.stackCoordinators.count == 0) try await process(route: .roomDetails(roomID: "1")) - XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) - XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 1) - XCTAssert(navigationStackCoordinator.stackCoordinators.first is RoomDetailsScreenCoordinator) + #expect(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) + #expect(navigationStackCoordinator.stackCoordinators.count == 1) + #expect(navigationStackCoordinator.stackCoordinators.first is RoomDetailsScreenCoordinator) } - func testChildRoomFlow() async throws { + @Test + func childRoomFlow() async throws { setupRoomFlowCoordinator() try await process(route: .room(roomID: "1", via: [])) - XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) - XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 0) + #expect(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) + #expect(navigationStackCoordinator.stackCoordinators.count == 0) try await process(route: .childRoom(roomID: "2", via: [])) - XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 1) - XCTAssert(navigationStackCoordinator.stackCoordinators.first is RoomScreenCoordinator) + #expect(navigationStackCoordinator.stackCoordinators.count == 1) + #expect(navigationStackCoordinator.stackCoordinators.first is RoomScreenCoordinator) try await process(route: .childRoom(roomID: "3", via: [])) - XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 2) - XCTAssert(navigationStackCoordinator.stackCoordinators.first is RoomScreenCoordinator) - XCTAssert(navigationStackCoordinator.stackCoordinators.last is RoomScreenCoordinator) + #expect(navigationStackCoordinator.stackCoordinators.count == 2) + #expect(navigationStackCoordinator.stackCoordinators.first is RoomScreenCoordinator) + #expect(navigationStackCoordinator.stackCoordinators.last is RoomScreenCoordinator) try await clearRoute(expectedActions: [.finished]) - XCTAssertNil(navigationStackCoordinator.rootCoordinator) - XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 0) + #expect(navigationStackCoordinator.rootCoordinator == nil) + #expect(navigationStackCoordinator.stackCoordinators.count == 0) } /// Tests the child flow teardown in isolation of it's parent. - func testChildFlowTearDown() async throws { + @Test + func childFlowTearDown() async throws { setupRoomFlowCoordinator(asChildFlow: true) navigationStackCoordinator.setRootCoordinator(BlankFormCoordinator()) try await process(route: .room(roomID: "1", via: [])) try await process(route: .roomDetails(roomID: "1")) - XCTAssertTrue(navigationStackCoordinator.rootCoordinator is BlankFormCoordinator, "A child room flow should push onto the stack, leaving the root alone.") - XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 2) - XCTAssertTrue(navigationStackCoordinator.stackCoordinators.first is RoomScreenCoordinator) - XCTAssertTrue(navigationStackCoordinator.stackCoordinators.last is RoomDetailsScreenCoordinator) + #expect(navigationStackCoordinator.rootCoordinator is BlankFormCoordinator, "A child room flow should push onto the stack, leaving the root alone.") + #expect(navigationStackCoordinator.stackCoordinators.count == 2) + #expect(navigationStackCoordinator.stackCoordinators.first is RoomScreenCoordinator) + #expect(navigationStackCoordinator.stackCoordinators.last is RoomDetailsScreenCoordinator) try await clearRoute(expectedActions: [.finished]) - XCTAssertTrue(navigationStackCoordinator.rootCoordinator is BlankFormCoordinator) - XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 2, "A child room flow should leave its parent to clean up the stack.") - XCTAssertTrue(navigationStackCoordinator.stackCoordinators.first is RoomScreenCoordinator, "A child room flow should leave its parent to clean up the stack.") - XCTAssertTrue(navigationStackCoordinator.stackCoordinators.last is RoomDetailsScreenCoordinator, "A child room flow should leave its parent to clean up the stack.") + #expect(navigationStackCoordinator.rootCoordinator is BlankFormCoordinator) + #expect(navigationStackCoordinator.stackCoordinators.count == 2, "A child room flow should leave its parent to clean up the stack.") + #expect(navigationStackCoordinator.stackCoordinators.first is RoomScreenCoordinator, "A child room flow should leave its parent to clean up the stack.") + #expect(navigationStackCoordinator.stackCoordinators.last is RoomDetailsScreenCoordinator, "A child room flow should leave its parent to clean up the stack.") } - func testChildRoomMemberDetails() async throws { + @Test + func childRoomMemberDetails() async throws { setupRoomFlowCoordinator() try await process(route: .room(roomID: "1", via: [])) - XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) - XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 0) + #expect(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) + #expect(navigationStackCoordinator.stackCoordinators.count == 0) try await process(route: .childRoom(roomID: "2", via: [])) - XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 1) - XCTAssert(navigationStackCoordinator.stackCoordinators.first is RoomScreenCoordinator) + #expect(navigationStackCoordinator.stackCoordinators.count == 1) + #expect(navigationStackCoordinator.stackCoordinators.first is RoomScreenCoordinator) try await process(route: .roomMemberDetails(userID: RoomMemberProxyMock.mockMe.userID)) - XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 2) - XCTAssert(navigationStackCoordinator.stackCoordinators.first is RoomScreenCoordinator) - XCTAssert(navigationStackCoordinator.stackCoordinators.last is RoomMemberDetailsScreenCoordinator) + #expect(navigationStackCoordinator.stackCoordinators.count == 2) + #expect(navigationStackCoordinator.stackCoordinators.first is RoomScreenCoordinator) + #expect(navigationStackCoordinator.stackCoordinators.last is RoomMemberDetailsScreenCoordinator) } - func testChildRoomIgnoresDirectDuplicate() async throws { + @Test + func childRoomIgnoresDirectDuplicate() async throws { setupRoomFlowCoordinator() try await process(route: .room(roomID: "1", via: [])) - XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) - XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 0) + #expect(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) + #expect(navigationStackCoordinator.stackCoordinators.count == 0) try await process(route: .childRoom(roomID: "1", via: [])) - XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 0, - "A room flow shouldn't present a direct child for the same room.") + #expect(navigationStackCoordinator.stackCoordinators.count == 0, + "A room flow shouldn't present a direct child for the same room.") try await process(route: .childRoom(roomID: "2", via: [])) - XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 1) - XCTAssert(navigationStackCoordinator.stackCoordinators.first is RoomScreenCoordinator) + #expect(navigationStackCoordinator.stackCoordinators.count == 1) + #expect(navigationStackCoordinator.stackCoordinators.first is RoomScreenCoordinator) try await process(route: .childRoom(roomID: "1", via: [])) - XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 2, - "Presenting the same room multiple times should be allowed when it's not a direct child of itself.") - XCTAssert(navigationStackCoordinator.stackCoordinators.first is RoomScreenCoordinator) - XCTAssert(navigationStackCoordinator.stackCoordinators.last is RoomScreenCoordinator) + #expect(navigationStackCoordinator.stackCoordinators.count == 2, + "Presenting the same room multiple times should be allowed when it's not a direct child of itself.") + #expect(navigationStackCoordinator.stackCoordinators.first is RoomScreenCoordinator) + #expect(navigationStackCoordinator.stackCoordinators.last is RoomScreenCoordinator) } - func testRoomMembershipInvite() async throws { + @Test + func roomMembershipInvite() async throws { setupRoomFlowCoordinator(roomType: .invited(roomID: "InvitedRoomID")) try await process(route: .room(roomID: "InvitedRoomID", via: [])) - XCTAssert(navigationStackCoordinator.rootCoordinator is JoinRoomScreenCoordinator) - XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 0) + #expect(navigationStackCoordinator.rootCoordinator is JoinRoomScreenCoordinator) + #expect(navigationStackCoordinator.stackCoordinators.count == 0) try await clearRoute(expectedActions: [.finished]) - XCTAssertNil(navigationStackCoordinator.rootCoordinator) + #expect(navigationStackCoordinator.rootCoordinator == nil) setupRoomFlowCoordinator(roomType: .invited(roomID: "InvitedRoomID")) try await process(route: .room(roomID: "InvitedRoomID", via: [])) - XCTAssert(navigationStackCoordinator.rootCoordinator is JoinRoomScreenCoordinator) - XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 0) + #expect(navigationStackCoordinator.rootCoordinator is JoinRoomScreenCoordinator) + #expect(navigationStackCoordinator.stackCoordinators.count == 0) // "Join" the room clientProxy.roomForIdentifierClosure = { _ in @@ -171,29 +181,30 @@ class RoomFlowCoordinatorTests: XCTestCase { } try await process(route: .room(roomID: "InvitedRoomID", via: [])) - XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) - XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 0) + #expect(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) + #expect(navigationStackCoordinator.stackCoordinators.count == 0) } - func testChildRoomMembershipInvite() async throws { + @Test + func childRoomMembershipInvite() async throws { setupRoomFlowCoordinator(asChildFlow: true, roomType: .invited(roomID: "InvitedRoomID")) navigationStackCoordinator.setRootCoordinator(BlankFormCoordinator()) try await process(route: .room(roomID: "InvitedRoomID", via: [])) - XCTAssertTrue(navigationStackCoordinator.rootCoordinator is BlankFormCoordinator, "A child room flow should push onto the stack, leaving the root alone.") - XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 1) - XCTAssertTrue(navigationStackCoordinator.stackCoordinators.last is JoinRoomScreenCoordinator) + #expect(navigationStackCoordinator.rootCoordinator is BlankFormCoordinator, "A child room flow should push onto the stack, leaving the root alone.") + #expect(navigationStackCoordinator.stackCoordinators.count == 1) + #expect(navigationStackCoordinator.stackCoordinators.last is JoinRoomScreenCoordinator) try await clearRoute(expectedActions: [.finished]) - XCTAssertNil(navigationStackCoordinator.stackCoordinators.last, "A child room flow should remove the join room scren on dismissal") + #expect(navigationStackCoordinator.stackCoordinators.last == nil, "A child room flow should remove the join room scren on dismissal") setupRoomFlowCoordinator(asChildFlow: true, roomType: .invited(roomID: "InvitedRoomID")) navigationStackCoordinator.setRootCoordinator(BlankFormCoordinator()) try await process(route: .room(roomID: "InvitedRoomID", via: [])) - XCTAssertTrue(navigationStackCoordinator.rootCoordinator is BlankFormCoordinator, "A child room flow should push onto the stack, leaving the root alone.") - XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 1) - XCTAssertTrue(navigationStackCoordinator.stackCoordinators.last is JoinRoomScreenCoordinator) + #expect(navigationStackCoordinator.rootCoordinator is BlankFormCoordinator, "A child room flow should push onto the stack, leaving the root alone.") + #expect(navigationStackCoordinator.stackCoordinators.count == 1) + #expect(navigationStackCoordinator.stackCoordinators.last is JoinRoomScreenCoordinator) // "Join" the room clientProxy.roomForIdentifierClosure = { _ in @@ -201,29 +212,31 @@ class RoomFlowCoordinatorTests: XCTestCase { } try await process(route: .room(roomID: "InvitedRoomID", via: [])) - XCTAssertTrue(navigationStackCoordinator.rootCoordinator is BlankFormCoordinator, "A child room flow should push onto the stack, leaving the root alone.") - XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 1) - XCTAssertTrue(navigationStackCoordinator.stackCoordinators.last is RoomScreenCoordinator) + #expect(navigationStackCoordinator.rootCoordinator is BlankFormCoordinator, "A child room flow should push onto the stack, leaving the root alone.") + #expect(navigationStackCoordinator.stackCoordinators.count == 1) + #expect(navigationStackCoordinator.stackCoordinators.last is RoomScreenCoordinator) } - func testEventRoute() async throws { + @Test + func eventRoute() async throws { setupRoomFlowCoordinator() try await process(route: .event(eventID: "1", roomID: "1", via: [])) - XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) - XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 0) + #expect(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) + #expect(navigationStackCoordinator.stackCoordinators.count == 0) try await process(route: .childEvent(eventID: "2", roomID: "1", via: [])) - XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) - XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 0) + #expect(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) + #expect(navigationStackCoordinator.stackCoordinators.count == 0) try await process(route: .childEvent(eventID: "3", roomID: "2", via: [])) - XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) - XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 1) - XCTAssert(navigationStackCoordinator.stackCoordinators.first is RoomScreenCoordinator) + #expect(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) + #expect(navigationStackCoordinator.stackCoordinators.count == 1) + #expect(navigationStackCoordinator.stackCoordinators.first is RoomScreenCoordinator) } - func testThreadedEventRoutes() async throws { + @Test + func threadedEventRoutes() async throws { ServiceLocator.shared.settings.threadsEnabled = true setupRoomFlowCoordinator() @@ -243,17 +256,17 @@ class RoomFlowCoordinatorTests: XCTestCase { } try await process(route: .event(eventID: "2", roomID: "1", via: [])) - XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) - XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 1) - XCTAssert(navigationStackCoordinator.stackCoordinators[0] is ThreadTimelineScreenCoordinator) + #expect(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) + try #require(navigationStackCoordinator.stackCoordinators.count == 1) // #require these counts so accessing by index is safe. + #expect(navigationStackCoordinator.stackCoordinators[0] is ThreadTimelineScreenCoordinator) // From the thread screen, navigate to another threaded event in the same room, and in the same thread. let threadCoordinator = navigationStackCoordinator.stackCoordinators[0] as? ThreadTimelineScreenCoordinator try await process(route: .childEvent(eventID: "3", roomID: "1", via: [])) - XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) - XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 1) - XCTAssert(navigationStackCoordinator.stackCoordinators[0] is ThreadTimelineScreenCoordinator) - XCTAssertIdentical(navigationStackCoordinator.stackCoordinators[0], threadCoordinator) + #expect(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) + try #require(navigationStackCoordinator.stackCoordinators.count == 1) + #expect(navigationStackCoordinator.stackCoordinators[0] is ThreadTimelineScreenCoordinator) + #expect(navigationStackCoordinator.stackCoordinators[0] === threadCoordinator) // Would be nice to test if the focusEvent function has been called but there is no way to mock that. // From the thread screen, navigate to another threaded event in the same room, but in a different thread. @@ -261,10 +274,10 @@ class RoomFlowCoordinatorTests: XCTestCase { mockedEvent.threadRootEventIdReturnValue = "4" roomProxy.loadOrFetchEventDetailsForReturnValue = .success(mockedEvent) try await process(route: .childEvent(eventID: "5", roomID: "1", via: [])) - XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) - XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 2) - XCTAssert(navigationStackCoordinator.stackCoordinators[0] is ThreadTimelineScreenCoordinator) - XCTAssert(navigationStackCoordinator.stackCoordinators[1] is ThreadTimelineScreenCoordinator) + #expect(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) + try #require(navigationStackCoordinator.stackCoordinators.count == 2) + #expect(navigationStackCoordinator.stackCoordinators[0] is ThreadTimelineScreenCoordinator) + #expect(navigationStackCoordinator.stackCoordinators[1] is ThreadTimelineScreenCoordinator) // From the thread screen, navigate to another threaded event in a different room. configuration = JoinedRoomProxyMockConfiguration(id: "2") @@ -282,12 +295,12 @@ class RoomFlowCoordinatorTests: XCTestCase { } try await process(route: .childEvent(eventID: "2", roomID: "2", via: [])) - XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) - XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 4) - XCTAssert(navigationStackCoordinator.stackCoordinators[0] is ThreadTimelineScreenCoordinator) - XCTAssert(navigationStackCoordinator.stackCoordinators[1] is ThreadTimelineScreenCoordinator) - XCTAssert(navigationStackCoordinator.stackCoordinators[2] is RoomScreenCoordinator) - XCTAssert(navigationStackCoordinator.stackCoordinators[3] is ThreadTimelineScreenCoordinator) + #expect(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) + try #require(navigationStackCoordinator.stackCoordinators.count == 4) + #expect(navigationStackCoordinator.stackCoordinators[0] is ThreadTimelineScreenCoordinator) + #expect(navigationStackCoordinator.stackCoordinators[1] is ThreadTimelineScreenCoordinator) + #expect(navigationStackCoordinator.stackCoordinators[2] is RoomScreenCoordinator) + #expect(navigationStackCoordinator.stackCoordinators[3] is ThreadTimelineScreenCoordinator) // From the thread screen, navigate to an event of the same room that is not threaded mockedEvent = TimelineEventSDKMock() @@ -295,66 +308,69 @@ class RoomFlowCoordinatorTests: XCTestCase { roomProxy.loadOrFetchEventDetailsForReturnValue = .success(mockedEvent) try await process(route: .childEvent(eventID: "3", roomID: "2", via: [])) - XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) - XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 5) - XCTAssert(navigationStackCoordinator.stackCoordinators[0] is ThreadTimelineScreenCoordinator) - XCTAssert(navigationStackCoordinator.stackCoordinators[1] is ThreadTimelineScreenCoordinator) - XCTAssert(navigationStackCoordinator.stackCoordinators[2] is RoomScreenCoordinator) - XCTAssert(navigationStackCoordinator.stackCoordinators[3] is ThreadTimelineScreenCoordinator) - XCTAssert(navigationStackCoordinator.stackCoordinators[4] is RoomScreenCoordinator) + #expect(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) + try #require(navigationStackCoordinator.stackCoordinators.count == 5) + #expect(navigationStackCoordinator.stackCoordinators[0] is ThreadTimelineScreenCoordinator) + #expect(navigationStackCoordinator.stackCoordinators[1] is ThreadTimelineScreenCoordinator) + #expect(navigationStackCoordinator.stackCoordinators[2] is RoomScreenCoordinator) + #expect(navigationStackCoordinator.stackCoordinators[3] is ThreadTimelineScreenCoordinator) + #expect(navigationStackCoordinator.stackCoordinators[4] is RoomScreenCoordinator) } - func testShareMediaRoute() async throws { + @Test + func shareMediaRoute() async throws { setupRoomFlowCoordinator() try await process(route: .room(roomID: "1", via: [])) - XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) - XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 0) + #expect(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) + #expect(navigationStackCoordinator.stackCoordinators.count == 0) let sharePayload: ShareExtensionPayload = .mediaFiles(roomID: "1", mediaFiles: [.init(url: .picturesDirectory, suggestedName: nil)]) try await process(route: .share(sharePayload)) - XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) - XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 0) + #expect(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) + #expect(navigationStackCoordinator.stackCoordinators.count == 0) - XCTAssertTrue((navigationStackCoordinator.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is MediaUploadPreviewScreenCoordinator) + #expect((navigationStackCoordinator.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is MediaUploadPreviewScreenCoordinator) try await process(route: .childRoom(roomID: "2", via: [])) - XCTAssertNil(navigationStackCoordinator.sheetCoordinator) - XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 1) + #expect(navigationStackCoordinator.sheetCoordinator == nil) + #expect(navigationStackCoordinator.stackCoordinators.count == 1) try await process(route: .share(sharePayload)) - XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 0) - XCTAssertTrue((navigationStackCoordinator.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is MediaUploadPreviewScreenCoordinator) + #expect(navigationStackCoordinator.stackCoordinators.count == 0) + #expect((navigationStackCoordinator.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is MediaUploadPreviewScreenCoordinator) } - func testShareTextRoute() async throws { + @Test + func shareTextRoute() async throws { setupRoomFlowCoordinator() try await process(route: .room(roomID: "1", via: [])) - XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) - XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 0) + #expect(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) + #expect(navigationStackCoordinator.stackCoordinators.count == 0) let sharePayload: ShareExtensionPayload = .text(roomID: "1", text: "Important text") try await process(route: .share(sharePayload)) - XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) - XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 0) + #expect(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) + #expect(navigationStackCoordinator.stackCoordinators.count == 0) - XCTAssertNil(navigationStackCoordinator.sheetCoordinator, "The media upload sheet shouldn't be shown when sharing text.") + #expect(navigationStackCoordinator.sheetCoordinator == nil, "The media upload sheet shouldn't be shown when sharing text.") try await process(route: .childRoom(roomID: "2", via: [])) - XCTAssertNil(navigationStackCoordinator.sheetCoordinator) - XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 1) + #expect(navigationStackCoordinator.sheetCoordinator == nil) + #expect(navigationStackCoordinator.stackCoordinators.count == 1) try await process(route: .share(sharePayload)) - XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 0) - XCTAssertNil(navigationStackCoordinator.sheetCoordinator, "The media upload sheet shouldn't be shown when sharing text.") + #expect(navigationStackCoordinator.stackCoordinators.count == 0) + #expect(navigationStackCoordinator.sheetCoordinator == nil, "The media upload sheet shouldn't be shown when sharing text.") } - func testLeavingRoom() async throws { + @Test + func leavingRoom() async throws { setupRoomFlowCoordinator() var configuration = JoinedRoomProxyMockConfiguration() @@ -381,15 +397,16 @@ class RoomFlowCoordinatorTests: XCTestCase { // MARK: - Spaces - func testSpacePermalink() async throws { + @Test + func spacePermalink() async throws { setupRoomFlowCoordinator() try await process(route: .room(roomID: "1", via: [])) - XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) + #expect(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) try await process(route: .childRoom(roomID: "space1", via: [])) - XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) - XCTAssert(navigationStackCoordinator.stackCoordinators.first is SpaceScreenCoordinator) + #expect(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) + #expect(navigationStackCoordinator.stackCoordinators.first is SpaceScreenCoordinator) } // MARK: - Private diff --git a/UnitTests/Sources/RoomNotificationSettingsScreenViewModelTests.swift b/UnitTests/Sources/RoomNotificationSettingsScreenViewModelTests.swift index 799944b5d..eeca58ce3 100644 --- a/UnitTests/Sources/RoomNotificationSettingsScreenViewModelTests.swift +++ b/UnitTests/Sources/RoomNotificationSettingsScreenViewModelTests.swift @@ -9,21 +9,22 @@ import Combine @testable import ElementX import MatrixRustSDK -import XCTest +import Testing +@Suite @MainActor -class RoomNotificationSettingsScreenViewModelTests: XCTestCase { +struct RoomNotificationSettingsScreenViewModelTests { var roomProxyMock: JoinedRoomProxyMock! var notificationSettingsProxyMock: NotificationSettingsProxyMock! var cancellables = Set() - override func setUpWithError() throws { - cancellables.removeAll() + init() { roomProxyMock = JoinedRoomProxyMock(.init(name: "Test")) notificationSettingsProxyMock = NotificationSettingsProxyMock(with: NotificationSettingsProxyMockConfiguration()) } - func testInitialStateDefaultModeEncryptedRoom() async throws { + @Test + func initialStateDefaultModeEncryptedRoom() async throws { let roomProxyMock = JoinedRoomProxyMock(.init(name: "Test", isEncrypted: true)) let notificationSettingsProxyMock = NotificationSettingsProxyMock(with: NotificationSettingsProxyMockConfiguration()) @@ -33,19 +34,20 @@ class RoomNotificationSettingsScreenViewModelTests: XCTestCase { roomProxy: roomProxyMock, displayAsUserDefinedRoomSettings: false) - let deferred = deferFulfillment(viewModel.context.$viewState) { state in + let deferred = deferFulfillment(viewModel.context.observe(\.viewState)) { state in state.notificationSettingsState.isLoaded } notificationSettingsProxyMock.callbacks.send(.settingsDidChange) try await deferred.fulfill() - XCTAssertFalse(viewModel.context.allowCustomSetting) - XCTAssertTrue(viewModel.context.viewState.shouldDisplayMentionsOnlyDisclaimer) - XCTAssertNotNil(viewModel.context.viewState.description(mode: .mentionsAndKeywordsOnly)) + #expect(!viewModel.context.allowCustomSetting) + #expect(viewModel.context.viewState.shouldDisplayMentionsOnlyDisclaimer) + #expect(viewModel.context.viewState.description(mode: .mentionsAndKeywordsOnly) != nil) } - func testInitialStateDefaultModeEncryptedRoomWithCanPushEncrypted() async throws { + @Test + func initialStateDefaultModeEncryptedRoomWithCanPushEncrypted() async throws { let roomProxyMock = JoinedRoomProxyMock(.init(name: "Test", isEncrypted: true)) let notificationSettingsProxyMock = NotificationSettingsProxyMock(with: .init(canPushEncryptedEvents: true)) @@ -55,19 +57,20 @@ class RoomNotificationSettingsScreenViewModelTests: XCTestCase { roomProxy: roomProxyMock, displayAsUserDefinedRoomSettings: false) - let deferred = deferFulfillment(viewModel.context.$viewState) { state in + let deferred = deferFulfillment(viewModel.context.observe(\.viewState)) { state in state.notificationSettingsState.isLoaded } notificationSettingsProxyMock.callbacks.send(.settingsDidChange) try await deferred.fulfill() - XCTAssertFalse(viewModel.context.allowCustomSetting) - XCTAssertFalse(viewModel.context.viewState.shouldDisplayMentionsOnlyDisclaimer) - XCTAssertNil(viewModel.context.viewState.description(mode: .mentionsAndKeywordsOnly)) + #expect(!viewModel.context.allowCustomSetting) + #expect(!viewModel.context.viewState.shouldDisplayMentionsOnlyDisclaimer) + #expect(viewModel.context.viewState.description(mode: .mentionsAndKeywordsOnly) == nil) } - func testInitialStateDefaultModeUnencryptedRoom() async throws { + @Test + func initialStateDefaultModeUnencryptedRoom() async throws { let roomProxyMock = JoinedRoomProxyMock(.init(name: "Test", isEncrypted: false)) let notificationSettingsProxyMock = NotificationSettingsProxyMock(with: NotificationSettingsProxyMockConfiguration()) @@ -77,39 +80,41 @@ class RoomNotificationSettingsScreenViewModelTests: XCTestCase { roomProxy: roomProxyMock, displayAsUserDefinedRoomSettings: false) - let deferred = deferFulfillment(viewModel.context.$viewState) { state in + let deferred = deferFulfillment(viewModel.context.observe(\.viewState)) { state in state.notificationSettingsState.isLoaded } notificationSettingsProxyMock.callbacks.send(.settingsDidChange) try await deferred.fulfill() - XCTAssertFalse(viewModel.context.allowCustomSetting) - XCTAssertFalse(viewModel.context.viewState.shouldDisplayMentionsOnlyDisclaimer) - XCTAssertNil(viewModel.context.viewState.description(mode: .mentionsAndKeywordsOnly)) + #expect(!viewModel.context.allowCustomSetting) + #expect(!viewModel.context.viewState.shouldDisplayMentionsOnlyDisclaimer) + #expect(viewModel.context.viewState.description(mode: .mentionsAndKeywordsOnly) == nil) } - func testInitialStateCustomMode() async throws { + @Test + func initialStateCustomMode() async throws { notificationSettingsProxyMock.getNotificationSettingsRoomIdIsEncryptedIsOneToOneReturnValue = RoomNotificationSettingsProxyMock(with: .init(mode: .mentionsAndKeywordsOnly, isDefault: false)) let viewModel = RoomNotificationSettingsScreenViewModel(notificationSettingsProxy: notificationSettingsProxyMock, roomProxy: roomProxyMock, displayAsUserDefinedRoomSettings: false) - let deferred = deferFulfillment(viewModel.context.$viewState) { state in + let deferred = deferFulfillment(viewModel.context.observe(\.viewState)) { state in state.notificationSettingsState.isLoaded } notificationSettingsProxyMock.callbacks.send(.settingsDidChange) try await deferred.fulfill() - XCTAssertTrue(viewModel.context.allowCustomSetting) + #expect(viewModel.context.allowCustomSetting) } - func testInitialStateFailure() async throws { + @Test + func initialStateFailure() async throws { notificationSettingsProxyMock.getNotificationSettingsRoomIdIsEncryptedIsOneToOneThrowableError = NotificationSettingsError.Generic(msg: "error") let viewModel = RoomNotificationSettingsScreenViewModel(notificationSettingsProxy: notificationSettingsProxyMock, roomProxy: roomProxyMock, displayAsUserDefinedRoomSettings: false) - let deferred = deferFulfillment(viewModel.context.$viewState) { state in + let deferred = deferFulfillment(viewModel.context.observe(\.viewState)) { state in state.notificationSettingsState.isError } @@ -119,25 +124,25 @@ class RoomNotificationSettingsScreenViewModelTests: XCTestCase { let expectedAlertInfo = AlertInfo(id: RoomNotificationSettingsScreenErrorType.loadingSettingsFailed, title: L10n.commonError, message: L10n.screenRoomNotificationSettingsErrorLoadingSettings) - XCTAssertEqual(viewModel.context.viewState.bindings.alertInfo?.id, expectedAlertInfo.id) - XCTAssertEqual(viewModel.context.viewState.bindings.alertInfo?.title, expectedAlertInfo.title) - XCTAssertEqual(viewModel.context.viewState.bindings.alertInfo?.message, expectedAlertInfo.message) + #expect(viewModel.context.viewState.bindings.alertInfo?.id == expectedAlertInfo.id) + #expect(viewModel.context.viewState.bindings.alertInfo?.title == expectedAlertInfo.title) + #expect(viewModel.context.viewState.bindings.alertInfo?.message == expectedAlertInfo.message) } - func testToggleAllCustomSettingOff() async throws { + @Test + func toggleAllCustomSettingOff() async throws { notificationSettingsProxyMock.getNotificationSettingsRoomIdIsEncryptedIsOneToOneReturnValue = RoomNotificationSettingsProxyMock(with: .init(mode: .mentionsAndKeywordsOnly, isDefault: false)) let viewModel = RoomNotificationSettingsScreenViewModel(notificationSettingsProxy: notificationSettingsProxyMock, roomProxy: roomProxyMock, displayAsUserDefinedRoomSettings: false) - let deferred = deferFulfillment(viewModel.context.$viewState) { state in + let deferred = deferFulfillment(viewModel.context.observe(\.viewState)) { state in state.notificationSettingsState.isLoaded } notificationSettingsProxyMock.callbacks.send(.settingsDidChange) try await deferred.fulfill() - let deferredIsRestoringDefaultSettings = deferFulfillment(viewModel.context.$viewState, - keyPath: \.isRestoringDefaultSetting, + let deferredIsRestoringDefaultSettings = deferFulfillment(viewModel.context.observe(\.viewState.isRestoringDefaultSetting), transitionValues: [false, true, false]) viewModel.state.bindings.allowCustomSetting = false @@ -145,18 +150,19 @@ class RoomNotificationSettingsScreenViewModelTests: XCTestCase { try await deferredIsRestoringDefaultSettings.fulfill() - XCTAssertEqual(notificationSettingsProxyMock.restoreDefaultNotificationModeRoomIdReceivedRoomId, roomProxyMock.id) - XCTAssertEqual(notificationSettingsProxyMock.restoreDefaultNotificationModeRoomIdCallsCount, 1) + #expect(notificationSettingsProxyMock.restoreDefaultNotificationModeRoomIdReceivedRoomId == roomProxyMock.id) + #expect(notificationSettingsProxyMock.restoreDefaultNotificationModeRoomIdCallsCount == 1) } - func testToggleAllCustomSettingOffOn() async throws { + @Test + func toggleAllCustomSettingOffOn() async throws { let notificationSettingsProxyMock = NotificationSettingsProxyMock(with: NotificationSettingsProxyMockConfiguration()) notificationSettingsProxyMock.getNotificationSettingsRoomIdIsEncryptedIsOneToOneReturnValue = RoomNotificationSettingsProxyMock(with: .init(mode: .mentionsAndKeywordsOnly, isDefault: true)) let viewModel = RoomNotificationSettingsScreenViewModel(notificationSettingsProxy: notificationSettingsProxyMock, roomProxy: roomProxyMock, displayAsUserDefinedRoomSettings: false) - var deferred = deferFulfillment(viewModel.context.$viewState) { state in + var deferred = deferFulfillment(viewModel.context.observe(\.viewState)) { state in state.notificationSettingsState.isLoaded } @@ -164,82 +170,75 @@ class RoomNotificationSettingsScreenViewModelTests: XCTestCase { try await deferred.fulfill() - deferred = deferFulfillment(viewModel.context.$viewState) { state in + deferred = deferFulfillment(viewModel.context.observe(\.viewState)) { state in state.notificationSettingsState.isLoaded } viewModel.state.bindings.allowCustomSetting = true viewModel.context.send(viewAction: .changedAllowCustomSettings) + await waitForConfirmation { confirmation in + notificationSettingsProxyMock.setNotificationModeRoomIdModeClosure = { id, mode in + #expect(id == roomProxyMock.id) + #expect(mode == .mentionsAndKeywordsOnly) + confirmation() + } + } try await deferred.fulfill() - - XCTAssertEqual(notificationSettingsProxyMock.setNotificationModeRoomIdModeReceivedArguments?.0, roomProxyMock.id) - XCTAssertEqual(notificationSettingsProxyMock.setNotificationModeRoomIdModeReceivedArguments?.1, .mentionsAndKeywordsOnly) - XCTAssertEqual(notificationSettingsProxyMock.setNotificationModeRoomIdModeCallsCount, 1) } - func testSetCustomMode() async throws { + @Test + func setCustomMode() async throws { notificationSettingsProxyMock.getNotificationSettingsRoomIdIsEncryptedIsOneToOneReturnValue = RoomNotificationSettingsProxyMock(with: .init(mode: .mentionsAndKeywordsOnly, isDefault: false)) let viewModel = RoomNotificationSettingsScreenViewModel(notificationSettingsProxy: notificationSettingsProxyMock, roomProxy: roomProxyMock, displayAsUserDefinedRoomSettings: false) - let deferredState = deferFulfillment(viewModel.context.$viewState) { state in + let deferred = deferFulfillment(viewModel.context.observe(\.viewState)) { state in state.notificationSettingsState.isLoaded } notificationSettingsProxyMock.callbacks.send(.settingsDidChange) - try await deferredState.fulfill() - - do { - viewModel.context.send(viewAction: .setCustomMode(.allMessages)) - - let deferredState = deferFulfillment(viewModel.context.$viewState) { state in - state.pendingCustomMode == nil - } - - try await deferredState.fulfill() - - XCTAssertEqual(notificationSettingsProxyMock.setNotificationModeRoomIdModeReceivedArguments?.0, roomProxyMock.id) - XCTAssertEqual(notificationSettingsProxyMock.setNotificationModeRoomIdModeReceivedArguments?.1, .allMessages) - XCTAssertEqual(notificationSettingsProxyMock.setNotificationModeRoomIdModeCallsCount, 1) - } + try await deferred.fulfill() - do { - viewModel.context.send(viewAction: .setCustomMode(.mute)) - - let deferredState = deferFulfillment(viewModel.context.$viewState) { state in - state.pendingCustomMode == nil - } - - try await deferredState.fulfill() - - XCTAssertEqual(notificationSettingsProxyMock.setNotificationModeRoomIdModeReceivedArguments?.0, roomProxyMock.id) - XCTAssertEqual(notificationSettingsProxyMock.setNotificationModeRoomIdModeReceivedArguments?.1, .mute) - XCTAssertEqual(notificationSettingsProxyMock.setNotificationModeRoomIdModeCallsCount, 2) - } + var deferredMode = deferFulfillment(viewModel.context.observe(\.viewState.pendingCustomMode), + transitionValues: [nil, .allMessages, nil]) + viewModel.context.send(viewAction: .setCustomMode(.allMessages)) - do { - viewModel.context.send(viewAction: .setCustomMode(.mentionsAndKeywordsOnly)) - - let deferredState = deferFulfillment(viewModel.context.$viewState) { state in - state.pendingCustomMode == nil - } - - try await deferredState.fulfill() - - XCTAssertEqual(notificationSettingsProxyMock.setNotificationModeRoomIdModeReceivedArguments?.0, roomProxyMock.id) - XCTAssertEqual(notificationSettingsProxyMock.setNotificationModeRoomIdModeReceivedArguments?.1, .mentionsAndKeywordsOnly) - XCTAssertEqual(notificationSettingsProxyMock.setNotificationModeRoomIdModeCallsCount, 3) - } + try await deferredMode.fulfill() + + #expect(notificationSettingsProxyMock.setNotificationModeRoomIdModeReceivedArguments?.0 == roomProxyMock.id) + #expect(notificationSettingsProxyMock.setNotificationModeRoomIdModeReceivedArguments?.1 == .allMessages) + #expect(notificationSettingsProxyMock.setNotificationModeRoomIdModeCallsCount == 1) + + deferredMode = deferFulfillment(viewModel.context.observe(\.viewState.pendingCustomMode), + transitionValues: [nil, .mute, nil]) + viewModel.context.send(viewAction: .setCustomMode(.mute)) + + try await deferredMode.fulfill() + + #expect(notificationSettingsProxyMock.setNotificationModeRoomIdModeReceivedArguments?.0 == roomProxyMock.id) + #expect(notificationSettingsProxyMock.setNotificationModeRoomIdModeReceivedArguments?.1 == .mute) + #expect(notificationSettingsProxyMock.setNotificationModeRoomIdModeCallsCount == 2) + + deferredMode = deferFulfillment(viewModel.context.observe(\.viewState.pendingCustomMode), + transitionValues: [nil, .mentionsAndKeywordsOnly, nil]) + viewModel.context.send(viewAction: .setCustomMode(.mentionsAndKeywordsOnly)) + + try await deferredMode.fulfill() + + #expect(notificationSettingsProxyMock.setNotificationModeRoomIdModeReceivedArguments?.0 == roomProxyMock.id) + #expect(notificationSettingsProxyMock.setNotificationModeRoomIdModeReceivedArguments?.1 == .mentionsAndKeywordsOnly) + #expect(notificationSettingsProxyMock.setNotificationModeRoomIdModeCallsCount == 3) } - func testDeleteCustomSettingTapped() async throws { + @Test + mutating func deleteCustomSettingTapped() async throws { notificationSettingsProxyMock.getNotificationSettingsRoomIdIsEncryptedIsOneToOneReturnValue = RoomNotificationSettingsProxyMock(with: .init(mode: .mentionsAndKeywordsOnly, isDefault: false)) let viewModel = RoomNotificationSettingsScreenViewModel(notificationSettingsProxy: notificationSettingsProxyMock, roomProxy: roomProxyMock, displayAsUserDefinedRoomSettings: true) - let deferred = deferFulfillment(viewModel.context.$viewState) { state in + let deferred = deferFulfillment(viewModel.context.observe(\.viewState)) { state in state.notificationSettingsState.isLoaded } @@ -253,8 +252,7 @@ class RoomNotificationSettingsScreenViewModelTests: XCTestCase { } .store(in: &cancellables) - let deferredViewState = deferFulfillment(viewModel.context.$viewState, - keyPath: \.deletingCustomSetting, + let deferredViewState = deferFulfillment(viewModel.context.observe(\.viewState.deletingCustomSetting), transitionValues: [false, true, false]) viewModel.context.send(viewAction: .deleteCustomSettingTapped) @@ -262,21 +260,22 @@ class RoomNotificationSettingsScreenViewModelTests: XCTestCase { try await deferredViewState.fulfill() // the `dismiss` action must have been sent - XCTAssertEqual(actionSent, .dismiss) + #expect(actionSent == .dismiss) // `restoreDefaultNotificationMode` should have been called - XCTAssert(notificationSettingsProxyMock.restoreDefaultNotificationModeRoomIdCalled) - XCTAssertEqual(notificationSettingsProxyMock.restoreDefaultNotificationModeRoomIdReceivedInvocations, [roomProxyMock.id]) + #expect(notificationSettingsProxyMock.restoreDefaultNotificationModeRoomIdCalled) + #expect(notificationSettingsProxyMock.restoreDefaultNotificationModeRoomIdReceivedInvocations == [roomProxyMock.id]) // and no alert is expected - XCTAssertNil(viewModel.context.alertInfo) + #expect(viewModel.context.alertInfo == nil) } - func testDeleteCustomSettingTappedFailure() async throws { + @Test + mutating func deleteCustomSettingTappedFailure() async throws { notificationSettingsProxyMock.getNotificationSettingsRoomIdIsEncryptedIsOneToOneReturnValue = RoomNotificationSettingsProxyMock(with: .init(mode: .mentionsAndKeywordsOnly, isDefault: false)) notificationSettingsProxyMock.restoreDefaultNotificationModeRoomIdThrowableError = NotificationSettingsError.Generic(msg: "error") let viewModel = RoomNotificationSettingsScreenViewModel(notificationSettingsProxy: notificationSettingsProxyMock, roomProxy: roomProxyMock, displayAsUserDefinedRoomSettings: true) - let deferred = deferFulfillment(viewModel.context.$viewState) { state in + let deferred = deferFulfillment(viewModel.context.observe(\.viewState)) { state in state.notificationSettingsState.isLoaded } @@ -290,8 +289,7 @@ class RoomNotificationSettingsScreenViewModelTests: XCTestCase { } .store(in: &cancellables) - let deferredViewState = deferFulfillment(viewModel.context.$viewState, - keyPath: \.deletingCustomSetting, + let deferredViewState = deferFulfillment(viewModel.context.observe(\.viewState.deletingCustomSetting), transitionValues: [false, true, false]) viewModel.context.send(viewAction: .deleteCustomSettingTapped) @@ -299,8 +297,8 @@ class RoomNotificationSettingsScreenViewModelTests: XCTestCase { try await deferredViewState.fulfill() // an alert is expected - XCTAssertEqual(viewModel.context.alertInfo?.id, .restoreDefaultFailed) + #expect(viewModel.context.alertInfo?.id == .restoreDefaultFailed) // the `dismiss` action must not have been sent - XCTAssertNil(actionSent) + #expect(actionSent == nil) } } diff --git a/UnitTests/Sources/RoomPollsHistoryScreenViewModelTests.swift b/UnitTests/Sources/RoomPollsHistoryScreenViewModelTests.swift index 703d3c339..47ac59390 100644 --- a/UnitTests/Sources/RoomPollsHistoryScreenViewModelTests.swift +++ b/UnitTests/Sources/RoomPollsHistoryScreenViewModelTests.swift @@ -7,15 +7,17 @@ // @testable import ElementX -import XCTest +import Foundation +import Testing +@Suite @MainActor -class RoomPollsHistoryScreenViewModelTests: XCTestCase { +struct RoomPollsHistoryScreenViewModelTests { var viewModel: RoomPollsHistoryScreenViewModelProtocol! var interactionHandler: PollInteractionHandlerMock! var timelineController: MockTimelineController! - override func setUpWithError() throws { + init() throws { interactionHandler = PollInteractionHandlerMock() timelineController = MockTimelineController() viewModel = RoomPollsHistoryScreenViewModel(pollInteractionHandler: interactionHandler, @@ -23,7 +25,8 @@ class RoomPollsHistoryScreenViewModelTests: XCTestCase { userIndicatorController: UserIndicatorControllerMock()) } - func testBackPaginate() async throws { + @Test + func backPaginate() async throws { timelineController.backPaginationResponses = [ [PollRoomTimelineItem.mock(poll: .emptyDisclosed, isEditable: true), PollRoomTimelineItem.mock(poll: .disclosed(createdByAccountOwner: true)), @@ -37,11 +40,12 @@ class RoomPollsHistoryScreenViewModelTests: XCTestCase { try await deferredViewState.fulfill() - XCTAssertEqual(viewModel.context.viewState.pollTimelineItems.count, 3) - XCTAssertFalse(viewModel.context.viewState.canBackPaginate) + #expect(viewModel.context.viewState.pollTimelineItems.count == 3) + #expect(!viewModel.context.viewState.canBackPaginate) } - func testBackPaginateCanBackPaginate() async throws { + @Test + func backPaginateCanBackPaginate() async throws { timelineController.backPaginationResponses = [ [PollRoomTimelineItem.mock(poll: .emptyDisclosed, isEditable: true), PollRoomTimelineItem.mock(poll: .disclosed(createdByAccountOwner: true)), @@ -56,11 +60,12 @@ class RoomPollsHistoryScreenViewModelTests: XCTestCase { try await deferredViewState.fulfill() - XCTAssertEqual(viewModel.context.viewState.pollTimelineItems.count, 3) - XCTAssert(viewModel.context.viewState.canBackPaginate) + #expect(viewModel.context.viewState.pollTimelineItems.count == 3) + #expect(viewModel.context.viewState.canBackPaginate) } - func testBackPaginateTwice() async throws { + @Test + func backPaginateTwice() async throws { timelineController.backPaginationResponses = [ [PollRoomTimelineItem.mock(poll: .emptyDisclosed, isEditable: true), PollRoomTimelineItem.mock(poll: .disclosed(createdByAccountOwner: true)), @@ -74,11 +79,12 @@ class RoomPollsHistoryScreenViewModelTests: XCTestCase { try await deferredViewState.fulfill() - XCTAssertEqual(viewModel.context.viewState.pollTimelineItems.count, 3) - XCTAssert(viewModel.context.viewState.canBackPaginate) + #expect(viewModel.context.viewState.pollTimelineItems.count == 3) + #expect(viewModel.context.viewState.canBackPaginate) } - func testFilters() async throws { + @Test + func filters() async throws { timelineController.backPaginationResponses = [ [PollRoomTimelineItem.mock(poll: .emptyDisclosed, isEditable: true), PollRoomTimelineItem.mock(poll: .disclosed(createdByAccountOwner: true)), @@ -96,13 +102,14 @@ class RoomPollsHistoryScreenViewModelTests: XCTestCase { try await deferredViewState.fulfill() - XCTAssertEqual(viewModel.context.viewState.pollTimelineItems.count, 3) + #expect(viewModel.context.viewState.pollTimelineItems.count == 3) viewModel.context.send(viewAction: .filter(.past)) - XCTAssertEqual(viewModel.context.viewState.pollTimelineItems.count, 1) + #expect(viewModel.context.viewState.pollTimelineItems.count == 1) } - func testEndPoll() async throws { + @Test + func endPoll() async throws { let deferred = deferFulfillment(interactionHandler.publisher.delay(for: 0.1, scheduler: DispatchQueue.main)) { _ in true } interactionHandler.endPollPollStartIDReturnValue = .success(()) @@ -110,11 +117,12 @@ class RoomPollsHistoryScreenViewModelTests: XCTestCase { try await deferred.fulfill() - XCTAssert(interactionHandler.endPollPollStartIDCalled) - XCTAssertEqual(interactionHandler.endPollPollStartIDReceivedPollStartID, "somePollID") + #expect(interactionHandler.endPollPollStartIDCalled) + #expect(interactionHandler.endPollPollStartIDReceivedPollStartID == "somePollID") } - func testEndPollFailure() async throws { + @Test + func endPollFailure() async throws { let deferred = deferFulfillment(viewModel.context.$viewState) { value in value.bindings.alertInfo != nil } @@ -124,11 +132,12 @@ class RoomPollsHistoryScreenViewModelTests: XCTestCase { try await deferred.fulfill() - XCTAssert(interactionHandler.endPollPollStartIDCalled) - XCTAssertEqual(interactionHandler.endPollPollStartIDReceivedPollStartID, "somePollID") + #expect(interactionHandler.endPollPollStartIDCalled) + #expect(interactionHandler.endPollPollStartIDReceivedPollStartID == "somePollID") } - func testSendPollResponse() async throws { + @Test + func sendPollResponse() async throws { let deferred = deferFulfillment(interactionHandler.publisher.delay(for: 0.1, scheduler: DispatchQueue.main)) { _ in true } interactionHandler.sendPollResponsePollStartIDOptionIDReturnValue = .success(()) @@ -136,12 +145,13 @@ class RoomPollsHistoryScreenViewModelTests: XCTestCase { try await deferred.fulfill() - XCTAssert(interactionHandler.sendPollResponsePollStartIDOptionIDCalled) - XCTAssertEqual(interactionHandler.sendPollResponsePollStartIDOptionIDReceivedInvocations[0].pollStartID, "somePollID") - XCTAssertEqual(interactionHandler.sendPollResponsePollStartIDOptionIDReceivedInvocations[0].optionID, "someOptionID") + #expect(interactionHandler.sendPollResponsePollStartIDOptionIDCalled) + #expect(interactionHandler.sendPollResponsePollStartIDOptionIDReceivedInvocations[0].pollStartID == "somePollID") + #expect(interactionHandler.sendPollResponsePollStartIDOptionIDReceivedInvocations[0].optionID == "someOptionID") } - func testSendPollResponseFailure() async throws { + @Test + func sendPollResponseFailure() async throws { let deferred = deferFulfillment(viewModel.context.$viewState) { value in value.bindings.alertInfo != nil } @@ -151,12 +161,13 @@ class RoomPollsHistoryScreenViewModelTests: XCTestCase { try await deferred.fulfill() - XCTAssert(interactionHandler.sendPollResponsePollStartIDOptionIDCalled) - XCTAssertEqual(interactionHandler.sendPollResponsePollStartIDOptionIDReceivedInvocations[0].pollStartID, "somePollID") - XCTAssertEqual(interactionHandler.sendPollResponsePollStartIDOptionIDReceivedInvocations[0].optionID, "someOptionID") + #expect(interactionHandler.sendPollResponsePollStartIDOptionIDCalled) + #expect(interactionHandler.sendPollResponsePollStartIDOptionIDReceivedInvocations[0].pollStartID == "somePollID") + #expect(interactionHandler.sendPollResponsePollStartIDOptionIDReceivedInvocations[0].optionID == "someOptionID") } - func testEditPoll() async throws { + @Test + func editPoll() async throws { let expectedPoll: Poll = .emptyDisclosed let expectedPollStartID = "someEventID" diff --git a/UnitTests/Sources/RoomScreenViewModelTests.swift b/UnitTests/Sources/RoomScreenViewModelTests.swift index 3fd25e3ef..9873a6949 100644 --- a/UnitTests/Sources/RoomScreenViewModelTests.swift +++ b/UnitTests/Sources/RoomScreenViewModelTests.swift @@ -8,31 +8,33 @@ import Combine @testable import ElementX +import Foundation import MatrixRustSDK import MatrixRustSDKMocks -import XCTest +import Testing +@Suite @MainActor -class RoomScreenViewModelTests: XCTestCase { +final class RoomScreenViewModelTests { private var viewModel: RoomScreenViewModel! - override func setUp() async throws { + init() async throws { AppSettings.resetAllSettings() } - override func tearDown() { - viewModel = nil + deinit { AppSettings.resetAllSettings() } - func testPinnedEventsBanner() async throws { + @Test + func pinnedEventsBanner() async throws { var configuration = JoinedRoomProxyMockConfiguration() - let timelineSubject = PassthroughSubject() + let (stream, continuation) = AsyncStream.makeStream(of: TimelineProxyProtocol.self) let infoSubject = CurrentValueSubject(RoomInfoProxyMock(configuration)) let roomProxyMock = JoinedRoomProxyMock(configuration) // setup a way to inject the mock of the pinned events timeline roomProxyMock.pinnedEventsTimelineClosure = { - guard let timeline = await timelineSubject.values.first() else { + guard let timeline = await stream.first() else { fatalError() } @@ -55,8 +57,8 @@ class RoomScreenViewModelTests: XCTestCase { viewState.pinnedEventsBannerState.count == 0 } try await deferred.fulfill() - XCTAssertTrue(viewModel.context.viewState.pinnedEventsBannerState.isLoading) - XCTAssertFalse(viewModel.context.viewState.shouldShowPinnedEventsBanner) + #expect(viewModel.context.viewState.pinnedEventsBannerState.isLoading) + #expect(!viewModel.context.viewState.shouldShowPinnedEventsBanner) // check if if after the pinned event ids are set the banner is still in a loading state, but is both loading and showing with a counter deferred = deferFulfillment(viewModel.context.$viewState) { viewState in @@ -65,9 +67,9 @@ class RoomScreenViewModelTests: XCTestCase { configuration.pinnedEventIDs = ["test1", "test2"] infoSubject.send(RoomInfoProxyMock(configuration)) try await deferred.fulfill() - XCTAssertTrue(viewModel.context.viewState.pinnedEventsBannerState.isLoading) - XCTAssertTrue(viewModel.context.viewState.shouldShowPinnedEventsBanner) - XCTAssertEqual(viewModel.context.viewState.pinnedEventsBannerState.selectedPinnedIndex, 1) + #expect(viewModel.context.viewState.pinnedEventsBannerState.isLoading) + #expect(viewModel.context.viewState.shouldShowPinnedEventsBanner) + #expect(viewModel.context.viewState.pinnedEventsBannerState.selectedPinnedIndex == 1) // setup the loaded pinned events injection in the timeline let pinnedTimelineMock = TimelineProxyMock() @@ -82,11 +84,11 @@ class RoomScreenViewModelTests: XCTestCase { deferred = deferFulfillment(viewModel.context.$viewState) { viewState in !viewState.pinnedEventsBannerState.isLoading } - timelineSubject.send(pinnedTimelineMock) + continuation.yield(pinnedTimelineMock) try await deferred.fulfill() - XCTAssertEqual(viewModel.context.viewState.pinnedEventsBannerState.count, 2) - XCTAssertTrue(viewModel.context.viewState.shouldShowPinnedEventsBanner) - XCTAssertEqual(viewModel.context.viewState.pinnedEventsBannerState.selectedPinnedIndex, 1) + #expect(viewModel.context.viewState.pinnedEventsBannerState.count == 2) + #expect(viewModel.context.viewState.shouldShowPinnedEventsBanner) + #expect(viewModel.context.viewState.pinnedEventsBannerState.selectedPinnedIndex == 1) // check if the banner is updating alongside the timeline deferred = deferFulfillment(viewModel.context.$viewState) { viewState in @@ -96,19 +98,20 @@ class RoomScreenViewModelTests: XCTestCase { .event(.init(item: EventTimelineItem(configuration: .init(eventID: "test2")), uniqueID: .init("2"))), .event(.init(item: EventTimelineItem(configuration: .init(eventID: "test3")), uniqueID: .init("3")))], .initial)) try await deferred.fulfill() - XCTAssertFalse(viewModel.context.viewState.pinnedEventsBannerState.isLoading) - XCTAssertTrue(viewModel.context.viewState.shouldShowPinnedEventsBanner) - XCTAssertEqual(viewModel.context.viewState.pinnedEventsBannerState.selectedPinnedIndex, 1) + #expect(!viewModel.context.viewState.pinnedEventsBannerState.isLoading) + #expect(viewModel.context.viewState.shouldShowPinnedEventsBanner) + #expect(viewModel.context.viewState.pinnedEventsBannerState.selectedPinnedIndex == 1) // check how the scrolling changes the banner visibility viewModel.timelineHasScrolled(direction: .top) - XCTAssertFalse(viewModel.context.viewState.shouldShowPinnedEventsBanner) + #expect(!viewModel.context.viewState.shouldShowPinnedEventsBanner) viewModel.timelineHasScrolled(direction: .bottom) - XCTAssertTrue(viewModel.context.viewState.shouldShowPinnedEventsBanner) + #expect(viewModel.context.viewState.shouldShowPinnedEventsBanner) } - func testPinnedEventsBannerSelection() async throws { + @Test + func pinnedEventsBannerSelection() async throws { let roomProxyMock = JoinedRoomProxyMock(.init()) roomProxyMock.loadOrFetchEventDetailsForReturnValue = .success(TimelineEventSDKMock()) // setup a way to inject the mock of the pinned events timeline @@ -135,10 +138,10 @@ class RoomScreenViewModelTests: XCTestCase { !viewState.pinnedEventsBannerState.isLoading } try await deferred.fulfill() - XCTAssertEqual(viewModel.context.viewState.pinnedEventsBannerState.count, 3) - XCTAssertTrue(viewModel.context.viewState.shouldShowPinnedEventsBanner) + #expect(viewModel.context.viewState.pinnedEventsBannerState.count == 3) + #expect(viewModel.context.viewState.shouldShowPinnedEventsBanner) // And that is actually displaying the `initialSelectedPinEventID` which is gthe first one in the list - XCTAssertEqual(viewModel.context.viewState.pinnedEventsBannerState.selectedPinnedIndex, 0) + #expect(viewModel.context.viewState.pinnedEventsBannerState.selectedPinnedIndex == 0) // check if the banner scrolls when tapping the previous pin deferred = deferFulfillment(viewModel.context.$viewState) { viewState in @@ -162,7 +165,8 @@ class RoomScreenViewModelTests: XCTestCase { try await deferred.fulfill() } - func testPinnedEventsBannerThreadedSelection() async throws { + @Test + func pinnedEventsBannerThreadedSelection() async throws { ServiceLocator.shared.settings.threadsEnabled = true let roomProxyMock = JoinedRoomProxyMock(.init()) @@ -195,10 +199,10 @@ class RoomScreenViewModelTests: XCTestCase { !viewState.pinnedEventsBannerState.isLoading } try await deferred.fulfill() - XCTAssertEqual(viewModel.context.viewState.pinnedEventsBannerState.count, 3) - XCTAssertTrue(viewModel.context.viewState.shouldShowPinnedEventsBanner) + #expect(viewModel.context.viewState.pinnedEventsBannerState.count == 3) + #expect(viewModel.context.viewState.shouldShowPinnedEventsBanner) // And that is actually displaying the `initialSelectedPinEventID` which is gthe first one in the list - XCTAssertEqual(viewModel.context.viewState.pinnedEventsBannerState.selectedPinnedIndex, 0) + #expect(viewModel.context.viewState.pinnedEventsBannerState.selectedPinnedIndex == 0) // check if the banner scrolls when tapping the previous pin deferred = deferFulfillment(viewModel.context.$viewState) { viewState in @@ -223,7 +227,8 @@ class RoomScreenViewModelTests: XCTestCase { try await deferredAction2.fulfill() } - func testRoomInfoUpdate() async throws { + @Test + func roomInfoUpdate() async throws { var configuration = JoinedRoomProxyMockConfiguration(id: "TestID", name: "StartingName", avatarURL: nil, hasOngoingCall: false) let roomProxyMock = JoinedRoomProxyMock(configuration) @@ -248,10 +253,10 @@ class RoomScreenViewModelTests: XCTestCase { userIndicatorController: ServiceLocator.shared.userIndicatorController) self.viewModel = viewModel - XCTAssertEqual(viewModel.state.roomTitle, "StartingName") - XCTAssertEqual(viewModel.state.roomAvatar, .room(id: "TestID", name: "StartingName", avatarURL: nil)) - XCTAssertFalse(viewModel.state.canJoinCall) - XCTAssertFalse(viewModel.state.hasOngoingCall) + #expect(viewModel.state.roomTitle == "StartingName") + #expect(viewModel.state.roomAvatar == .room(id: "TestID", name: "StartingName", avatarURL: nil)) + #expect(!viewModel.state.canJoinCall) + #expect(!viewModel.state.hasOngoingCall) let deferred = deferFulfillment(viewModel.context.$viewState) { viewState in viewState.roomTitle == "NewName" && @@ -270,7 +275,8 @@ class RoomScreenViewModelTests: XCTestCase { try await deferred.fulfill() } - func testCallButtonVisibility() async throws { + @Test + func callButtonVisibility() async throws { // Given a room screen with no ongoing call. let ongoingCallRoomIDSubject = CurrentValueSubject(nil) let roomProxyMock = JoinedRoomProxyMock(.init(id: "MyRoomID")) @@ -283,7 +289,7 @@ class RoomScreenViewModelTests: XCTestCase { analyticsService: ServiceLocator.shared.analytics, userIndicatorController: ServiceLocator.shared.userIndicatorController) self.viewModel = viewModel - XCTAssertTrue(viewModel.state.shouldShowCallButton) + #expect(viewModel.state.shouldShowCallButton) // When a call starts in this room. var deferred = deferFulfillment(viewModel.context.$viewState) { !$0.shouldShowCallButton } @@ -291,7 +297,7 @@ class RoomScreenViewModelTests: XCTestCase { try await deferred.fulfill() // Then the call button should be hidden. - XCTAssertFalse(viewModel.state.shouldShowCallButton) + #expect(!viewModel.state.shouldShowCallButton) // When a call starts in a different room. deferred = deferFulfillment(viewModel.context.$viewState) { $0.shouldShowCallButton } @@ -299,41 +305,43 @@ class RoomScreenViewModelTests: XCTestCase { try await deferred.fulfill() // Then the call button should be shown again. - XCTAssertTrue(viewModel.state.shouldShowCallButton) + #expect(viewModel.state.shouldShowCallButton) // When the call from the other room finishes. - let deferredFailure = deferFailure(viewModel.context.$viewState, timeout: 1) { !$0.shouldShowCallButton } + let deferredFailure = deferFailure(viewModel.context.$viewState, timeout: .seconds(1)) { !$0.shouldShowCallButton } ongoingCallRoomIDSubject.send(nil) try await deferredFailure.fulfill() // Then the call button should remain visible shown. - XCTAssertTrue(viewModel.state.shouldShowCallButton) + #expect(viewModel.state.shouldShowCallButton) } - func testRoomFullyRead() async { - let expectation = XCTestExpectation(description: "Wait for fully read") - let roomProxyMock = JoinedRoomProxyMock(.init(id: "MyRoomID")) - roomProxyMock.markAsReadReceiptTypeClosure = { readReceiptType in - XCTAssertEqual(readReceiptType, .fullyRead) - expectation.fulfill() - return .success(()) + @Test + func roomFullyRead() async { + await waitForConfirmation("Wait for fully read") { confirm in + let roomProxyMock = JoinedRoomProxyMock(.init(id: "MyRoomID")) + roomProxyMock.markAsReadReceiptTypeClosure = { readReceiptType in + #expect(readReceiptType == .fullyRead) + confirm() + return .success(()) + } + let viewModel = RoomScreenViewModel(userSession: UserSessionMock(.init()), + roomProxy: roomProxyMock, + initialSelectedPinnedEventID: nil, + ongoingCallRoomIDPublisher: .init(.init(nil)), + appSettings: ServiceLocator.shared.settings, + appHooks: AppHooks(), + analyticsService: ServiceLocator.shared.analytics, + userIndicatorController: ServiceLocator.shared.userIndicatorController) + self.viewModel = viewModel + viewModel.stop() } - let viewModel = RoomScreenViewModel(userSession: UserSessionMock(.init()), - roomProxy: roomProxyMock, - initialSelectedPinnedEventID: nil, - ongoingCallRoomIDPublisher: .init(.init(nil)), - appSettings: ServiceLocator.shared.settings, - appHooks: AppHooks(), - analyticsService: ServiceLocator.shared.analytics, - userIndicatorController: ServiceLocator.shared.userIndicatorController) - self.viewModel = viewModel - viewModel.stop() - await fulfillment(of: [expectation]) } // MARK: - Knock Requests - func testKnockRequestBanner() async throws { + @Test + func knockRequestBanner() async throws { ServiceLocator.shared.settings.knockingEnabled = true let roomProxyMock = JoinedRoomProxyMock(.init(knockRequestsState: .loaded([KnockRequestProxyMock(.init(eventID: "1", userID: "@alice:matrix.org", displayName: "Alice", reason: "Hello World!")), // This one should be filtered @@ -367,7 +375,8 @@ class RoomScreenViewModelTests: XCTestCase { try await deferred.fulfill() } - func testKnockRequestBannerMarkAsSeen() async throws { + @Test + func knockRequestBannerMarkAsSeen() async throws { ServiceLocator.shared.settings.knockingEnabled = true let roomProxyMock = JoinedRoomProxyMock(.init(knockRequestsState: .loaded([KnockRequestProxyMock(.init(eventID: "1", userID: "@alice:matrix.org", displayName: "Alice", reason: "Hello World!")), // This one should be filtered @@ -398,7 +407,8 @@ class RoomScreenViewModelTests: XCTestCase { try await deferred.fulfill() } - func testLoadingKnockRequests() async throws { + @Test + func loadingKnockRequests() async throws { ServiceLocator.shared.settings.knockingEnabled = true let roomProxyMock = JoinedRoomProxyMock(.init(knockRequestsState: .loading, joinRule: .knock)) @@ -417,7 +427,8 @@ class RoomScreenViewModelTests: XCTestCase { try await deferred.fulfill() } - func testKnockRequestsBannerDoesNotAppearIfUserHasNoPermission() async throws { + @Test + func knockRequestsBannerDoesNotAppearIfUserHasNoPermission() async throws { ServiceLocator.shared.settings.knockingEnabled = true let roomProxyMock = JoinedRoomProxyMock(.init(knockRequestsState: .loaded([KnockRequestProxyMock(.init(eventID: "1", userID: "@alice:matrix.org", displayName: "Alice", reason: "Hello World!"))]), joinRule: .knock, @@ -441,7 +452,8 @@ class RoomScreenViewModelTests: XCTestCase { // MARK: - History Sharing - func testRoomWithSharedHistoryDoesNotDisplayBadgeIfFeatureFlagNotSet() async throws { + @Test + func roomWithSharedHistoryDoesNotDisplayBadgeIfFeatureFlagNotSet() async throws { ServiceLocator.shared.settings.enableKeyShareOnInvite = false var configuration = JoinedRoomProxyMockConfiguration(historyVisibility: .joined) @@ -461,7 +473,7 @@ class RoomScreenViewModelTests: XCTestCase { self.viewModel = viewModel let deferredInvisible = deferFailure(viewModel.context.$viewState, - timeout: 1, + timeout: .seconds(1), message: "The icon should not be shown when the room history visibility is not .shared or .worldReadable") { viewState in viewState.roomHistorySharingState != nil } @@ -470,14 +482,15 @@ class RoomScreenViewModelTests: XCTestCase { configuration.historyVisibility = .shared infoSubject.send(RoomInfoProxyMock(configuration)) let deferredShared = deferFailure(viewModel.context.$viewState, - timeout: 1, + timeout: .seconds(1), message: "The icon should not be shown when the room history visibility is .shared, since the flag isn't set") { viewState in viewState.roomHistorySharingState != nil } try await deferredShared.fulfill() } - func testRoomWithSharedHistoryDisplaysBadgeWhenFeatureFlagSet() async throws { + @Test + func roomWithSharedHistoryDisplaysBadgeWhenFeatureFlagSet() async throws { ServiceLocator.shared.settings.enableKeyShareOnInvite = true var configuration = JoinedRoomProxyMockConfiguration(isEncrypted: false, historyVisibility: .joined) @@ -497,7 +510,7 @@ class RoomScreenViewModelTests: XCTestCase { self.viewModel = viewModel let deferredInvisible = deferFailure(viewModel.context.$viewState, - timeout: 1, + timeout: .seconds(1), message: "The icon should be hidden when the room history visibility is not .shared or .worldReadable") { viewState in viewState.roomHistorySharingState != nil } @@ -506,7 +519,7 @@ class RoomScreenViewModelTests: XCTestCase { configuration.historyVisibility = .shared infoSubject.send(RoomInfoProxyMock(configuration)) let deferredInvisibleUnencrypted = deferFailure(viewModel.context.$viewState, - timeout: 1, + timeout: .seconds(1), message: "The icon should not be shown when the room is unencrypted") { viewState in viewState.roomHistorySharingState != nil } diff --git a/UnitTests/Sources/SecurityAndPrivacyScreenViewModelTests.swift b/UnitTests/Sources/SecurityAndPrivacyScreenViewModelTests.swift index 0e3eab11b..2b4ad8094 100644 --- a/UnitTests/Sources/SecurityAndPrivacyScreenViewModelTests.swift +++ b/UnitTests/Sources/SecurityAndPrivacyScreenViewModelTests.swift @@ -9,10 +9,11 @@ import Combine @testable import ElementX import MatrixRustSDK -import XCTest +import Testing +@Suite @MainActor -class SecurityAndPrivacyScreenViewModelTests: XCTestCase { +final class SecurityAndPrivacyScreenViewModelTests { var viewModel: SecurityAndPrivacyScreenViewModelProtocol! var spaceServiceProxy: SpaceServiceProxyMock! var roomProxy: JoinedRoomProxyMock! @@ -21,13 +22,18 @@ class SecurityAndPrivacyScreenViewModelTests: XCTestCase { viewModel.context } - override func tearDown() { + init() { + AppSettings.resetAllSettings() + } + + deinit { viewModel = nil roomProxy = nil AppSettings.resetAllSettings() } - func testSetSingleJoinedSpaceMembersAccess() async throws { + @Test + func setSingleJoinedSpaceMembersAccess() async throws { let singleRoom = [SpaceServiceRoom].mockSingleRoom let space = singleRoom[0] setupViewModel(joinedParentSpaces: singleRoom, joinRule: .public) @@ -35,30 +41,31 @@ class SecurityAndPrivacyScreenViewModelTests: XCTestCase { let deferred = deferFulfillment(context.$viewState) { $0.selectableJoinedSpaces.count == 1 } try await deferred.fulfill() - XCTAssertEqual(context.viewState.currentSettings.accessType, .anyone) - XCTAssertTrue(context.viewState.isSaveDisabled) - XCTAssertTrue(context.viewState.isSpaceMembersOptionSelectable) + #expect(context.viewState.currentSettings.accessType == .anyone) + #expect(context.viewState.isSaveDisabled) + #expect(context.viewState.isSpaceMembersOptionSelectable) guard case .singleJoined = context.viewState.spaceSelection else { - XCTFail("Expected spaceSelection to be .singleJoined") + Issue.record("Expected spaceSelection to be .singleJoined") return } context.send(viewAction: .selectedSpaceMembersAccess) - XCTAssertEqual(context.desiredSettings.accessType, .spaceMembers(spaceIDs: [space.id])) - XCTAssertFalse(context.viewState.shouldShowAccessSectionFooter) - XCTAssertFalse(context.viewState.isSaveDisabled) + #expect(context.desiredSettings.accessType == .spaceMembers(spaceIDs: [space.id])) + #expect(!context.viewState.shouldShowAccessSectionFooter) + #expect(!context.viewState.isSaveDisabled) - let expectation = expectation(description: "Join rule has updated") - roomProxy.updateJoinRuleClosure = { value in - XCTAssertEqual(value, .restricted(rules: [.roomMembership(roomID: space.id)])) - expectation.fulfill() - return .success(()) + await waitForConfirmation("Join rule has updated") { confirm in + roomProxy.updateJoinRuleClosure = { value in + #expect(value == .restricted(rules: [.roomMembership(roomID: space.id)])) + confirm() + return .success(()) + } + context.send(viewAction: .save) } - context.send(viewAction: .save) - await fulfillment(of: [expectation]) } - func testSetSingleJoinedAskToJoinWithSpaceMembersAccess() async throws { + @Test + func setSingleJoinedAskToJoinWithSpaceMembersAccess() async throws { let singleRoom = [SpaceServiceRoom].mockSingleRoom let space = singleRoom[0] setupViewModel(joinedParentSpaces: singleRoom, joinRule: .public) @@ -66,30 +73,31 @@ class SecurityAndPrivacyScreenViewModelTests: XCTestCase { let deferred = deferFulfillment(context.$viewState) { $0.selectableJoinedSpaces.count == 1 } try await deferred.fulfill() - XCTAssertEqual(context.viewState.currentSettings.accessType, .anyone) - XCTAssertTrue(context.viewState.isSaveDisabled) - XCTAssertTrue(context.viewState.isAskToJoinWithSpaceMembersOptionSelectable) + #expect(context.viewState.currentSettings.accessType == .anyone) + #expect(context.viewState.isSaveDisabled) + #expect(context.viewState.isAskToJoinWithSpaceMembersOptionSelectable) guard case .singleJoined = context.viewState.spaceSelection else { - XCTFail("Expected spaceSelection to be .singleJoined") + Issue.record("Expected spaceSelection to be .singleJoined") return } context.send(viewAction: .selectedAskToJoinWithSpaceMembersAccess) - XCTAssertEqual(context.desiredSettings.accessType, .askToJoinWithSpaceMembers(spaceIDs: [space.id])) - XCTAssertFalse(context.viewState.shouldShowAccessSectionFooter) - XCTAssertFalse(context.viewState.isSaveDisabled) + #expect(context.desiredSettings.accessType == .askToJoinWithSpaceMembers(spaceIDs: [space.id])) + #expect(!context.viewState.shouldShowAccessSectionFooter) + #expect(!context.viewState.isSaveDisabled) - let expectation = expectation(description: "Join rule has updated") - roomProxy.updateJoinRuleClosure = { value in - XCTAssertEqual(value, .knockRestricted(rules: [.roomMembership(roomID: space.id)])) - expectation.fulfill() - return .success(()) + await waitForConfirmation("Join rule has updated") { confirm in + roomProxy.updateJoinRuleClosure = { value in + #expect(value == .knockRestricted(rules: [.roomMembership(roomID: space.id)])) + confirm() + return .success(()) + } + context.send(viewAction: .save) } - context.send(viewAction: .save) - await fulfillment(of: [expectation]) } - func testSingleUnknownSpaceMembersAccessCanBeReselected() async throws { + @Test + func singleUnknownSpaceMembersAccessCanBeReselected() async throws { let singleRoom = [SpaceServiceRoom].mockSingleRoom let space = singleRoom[0] setupViewModel(joinedParentSpaces: [], joinRule: .restricted(rules: [.roomMembership(roomID: space.id)])) @@ -97,41 +105,43 @@ class SecurityAndPrivacyScreenViewModelTests: XCTestCase { let deferred = deferFulfillment(context.$viewState) { $0.selectableJoinedSpaces.count == 0 } try await deferred.fulfill() - XCTAssertEqual(context.viewState.currentSettings.accessType, .spaceMembers(spaceIDs: [space.id])) - XCTAssertEqual(context.desiredSettings, context.viewState.currentSettings) - XCTAssertTrue(context.viewState.isSpaceMembersOptionSelectable) - XCTAssertFalse(context.viewState.shouldShowAccessSectionFooter) - XCTAssertTrue(context.viewState.isSaveDisabled) + #expect(context.viewState.currentSettings.accessType == .spaceMembers(spaceIDs: [space.id])) + #expect(context.desiredSettings == context.viewState.currentSettings) + #expect(context.viewState.isSpaceMembersOptionSelectable) + #expect(!context.viewState.shouldShowAccessSectionFooter) + #expect(context.viewState.isSaveDisabled) guard case .singleUnknown = context.viewState.spaceSelection else { - XCTFail("Expected spaceSelection to be .singleUnknown") + Issue.record("Expected spaceSelection to be .singleUnknown") return } + let saveDeferred = deferFulfillment(context.$viewState) { !$0.isSaveDisabled } context.desiredSettings.accessType = .anyone - XCTAssertTrue(context.viewState.isSpaceMembersOptionSelectable) - XCTAssertFalse(context.viewState.isSaveDisabled) + try await saveDeferred.fulfill() + #expect(context.viewState.isSpaceMembersOptionSelectable) context.send(viewAction: .selectedSpaceMembersAccess) - XCTAssertTrue(context.viewState.isSaveDisabled) - XCTAssertEqual(context.desiredSettings.accessType, .spaceMembers(spaceIDs: [space.id])) + #expect(context.viewState.isSaveDisabled) + #expect(context.desiredSettings.accessType == .spaceMembers(spaceIDs: [space.id])) guard case .singleUnknown = context.viewState.spaceSelection else { - XCTFail("Expected spaceSelection to be .singleUnknown") + Issue.record("Expected spaceSelection to be .singleUnknown") return } } - func testMultipleKnownSpacesMembersSelection() async throws { + @Test + func multipleKnownSpacesMembersSelection() async throws { let spaces = [SpaceServiceRoom].mockJoinedSpaces2 setupViewModel(joinedParentSpaces: spaces, joinRule: .public) let deferred = deferFulfillment(context.$viewState) { $0.selectableJoinedSpaces.count == 3 } try await deferred.fulfill() - XCTAssertEqual(context.viewState.currentSettings.accessType, .anyone) - XCTAssertTrue(context.viewState.isSaveDisabled) - XCTAssertTrue(context.viewState.isSpaceMembersOptionSelectable) + #expect(context.viewState.currentSettings.accessType == .anyone) + #expect(context.viewState.isSaveDisabled) + #expect(context.viewState.isSpaceMembersOptionSelectable) guard case .multiple = context.viewState.spaceSelection else { - XCTFail("Expected spaceSelection to be .multiple") + Issue.record("Expected spaceSelection to be .multiple") return } @@ -150,32 +160,33 @@ class SecurityAndPrivacyScreenViewModelTests: XCTestCase { context.send(viewAction: .selectedSpaceMembersAccess) try await deferredAction.fulfill() selectedIDs.send([spaces[0].id]) - XCTAssertEqual(context.desiredSettings.accessType, .spaceMembers(spaceIDs: [spaces[0].id])) - XCTAssertTrue(context.viewState.shouldShowAccessSectionFooter) - XCTAssertFalse(context.viewState.isSaveDisabled) + #expect(context.desiredSettings.accessType == .spaceMembers(spaceIDs: [spaces[0].id])) + #expect(context.viewState.shouldShowAccessSectionFooter) + #expect(!context.viewState.isSaveDisabled) - let expectation = expectation(description: "Join rule has updated") - roomProxy.updateJoinRuleClosure = { value in - XCTAssertEqual(value, .restricted(rules: [.roomMembership(roomID: spaces[0].id)])) - expectation.fulfill() - return .success(()) + await waitForConfirmation("Join rule has updated") { confirm in + roomProxy.updateJoinRuleClosure = { value in + #expect(value == .restricted(rules: [.roomMembership(roomID: spaces[0].id)])) + confirm() + return .success(()) + } + context.send(viewAction: .save) } - context.send(viewAction: .save) - await fulfillment(of: [expectation]) } - func testMultipleKnownAskToJoinSpacesMembersSelection() async throws { + @Test + func multipleKnownAskToJoinSpacesMembersSelection() async throws { let spaces = [SpaceServiceRoom].mockJoinedSpaces2 setupViewModel(joinedParentSpaces: spaces, joinRule: .public) let deferred = deferFulfillment(context.$viewState) { $0.selectableJoinedSpaces.count == 3 } try await deferred.fulfill() - XCTAssertEqual(context.viewState.currentSettings.accessType, .anyone) - XCTAssertTrue(context.viewState.isSaveDisabled) - XCTAssertTrue(context.viewState.isAskToJoinWithSpaceMembersOptionSelectable) + #expect(context.viewState.currentSettings.accessType == .anyone) + #expect(context.viewState.isSaveDisabled) + #expect(context.viewState.isAskToJoinWithSpaceMembersOptionSelectable) guard case .multiple = context.viewState.spaceSelection else { - XCTFail("Expected spaceSelection to be .multiple") + Issue.record("Expected spaceSelection to be .multiple") return } @@ -194,21 +205,22 @@ class SecurityAndPrivacyScreenViewModelTests: XCTestCase { context.send(viewAction: .selectedAskToJoinWithSpaceMembersAccess) try await deferredAction.fulfill() selectedIDs.send([spaces[0].id]) - XCTAssertEqual(context.desiredSettings.accessType, .askToJoinWithSpaceMembers(spaceIDs: [spaces[0].id])) - XCTAssertTrue(context.viewState.shouldShowAccessSectionFooter) - XCTAssertFalse(context.viewState.isSaveDisabled) + #expect(context.desiredSettings.accessType == .askToJoinWithSpaceMembers(spaceIDs: [spaces[0].id])) + #expect(context.viewState.shouldShowAccessSectionFooter) + #expect(!context.viewState.isSaveDisabled) - let expectation = expectation(description: "Join rule has updated") - roomProxy.updateJoinRuleClosure = { value in - XCTAssertEqual(value, .knockRestricted(rules: [.roomMembership(roomID: spaces[0].id)])) - expectation.fulfill() - return .success(()) + await waitForConfirmation("Join rule has updated") { confirm in + roomProxy.updateJoinRuleClosure = { value in + #expect(value == .knockRestricted(rules: [.roomMembership(roomID: spaces[0].id)])) + confirm() + return .success(()) + } + context.send(viewAction: .save) } - context.send(viewAction: .save) - await fulfillment(of: [expectation]) } - func testMultipleSpacesMembersSelection() async throws { + @Test + func multipleSpacesMembersSelection() async throws { let spaces = [SpaceServiceRoom].mockJoinedSpaces2 setupViewModel(joinedParentSpaces: spaces, joinRule: .restricted(rules: [.roomMembership(roomID: "unknownSpaceID")])) @@ -216,11 +228,11 @@ class SecurityAndPrivacyScreenViewModelTests: XCTestCase { let deferred = deferFulfillment(context.$viewState) { $0.selectableSpacesCount == 4 } try await deferred.fulfill() - XCTAssertTrue(context.viewState.currentSettings.accessType.isSpaceMembers) - XCTAssertTrue(context.viewState.isSaveDisabled) - XCTAssertTrue(context.viewState.isSpaceMembersOptionSelectable) + #expect(context.viewState.currentSettings.accessType.isSpaceMembers) + #expect(context.viewState.isSaveDisabled) + #expect(context.viewState.isSpaceMembersOptionSelectable) guard case .multiple = context.viewState.spaceSelection else { - XCTFail("Expected spaceSelection to be .multiple") + Issue.record("Expected spaceSelection to be .multiple") return } @@ -240,21 +252,22 @@ class SecurityAndPrivacyScreenViewModelTests: XCTestCase { context.send(viewAction: .manageSpaces) try await deferredAction.fulfill() selectedIDs.send([spaces[0].id, "unknownSpaceID"]) - XCTAssertEqual(context.desiredSettings.accessType, .spaceMembers(spaceIDs: [spaces[0].id, "unknownSpaceID"])) - XCTAssertTrue(context.viewState.shouldShowAccessSectionFooter) - XCTAssertFalse(context.viewState.isSaveDisabled) + #expect(context.desiredSettings.accessType == .spaceMembers(spaceIDs: [spaces[0].id, "unknownSpaceID"])) + #expect(context.viewState.shouldShowAccessSectionFooter) + #expect(!context.viewState.isSaveDisabled) - let expectation = expectation(description: "Join rule has updated") - roomProxy.updateJoinRuleClosure = { value in - XCTAssertEqual(value, .restricted(rules: [.roomMembership(roomID: spaces[0].id), .roomMembership(roomID: "unknownSpaceID")])) - expectation.fulfill() - return .success(()) + await waitForConfirmation("Join rule has updated") { confirm in + roomProxy.updateJoinRuleClosure = { value in + #expect(value == .restricted(rules: [.roomMembership(roomID: spaces[0].id), .roomMembership(roomID: "unknownSpaceID")])) + confirm() + return .success(()) + } + context.send(viewAction: .save) } - context.send(viewAction: .save) - await fulfillment(of: [expectation]) } - func testMultipleSpacesMembersSelectionWithAnExistingNonParentButJoinedSpace() async throws { + @Test + func multipleSpacesMembersSelectionWithAnExistingNonParentButJoinedSpace() async throws { let joinedParentSpaces = [SpaceServiceRoom].mockJoinedSpaces2 let singleRoom = [SpaceServiceRoom].mockSingleRoom let space = singleRoom[0] @@ -267,11 +280,11 @@ class SecurityAndPrivacyScreenViewModelTests: XCTestCase { let deferred = deferFulfillment(context.$viewState) { $0.selectableSpacesCount == 5 } try await deferred.fulfill() - XCTAssertTrue(context.viewState.currentSettings.accessType.isSpaceMembers) - XCTAssertTrue(context.viewState.isSaveDisabled) - XCTAssertTrue(context.viewState.isSpaceMembersOptionSelectable) + #expect(context.viewState.currentSettings.accessType.isSpaceMembers) + #expect(context.viewState.isSaveDisabled) + #expect(context.viewState.isSpaceMembersOptionSelectable) guard case .multiple = context.viewState.spaceSelection else { - XCTFail("Expected spaceSelection to be .multiple") + Issue.record("Expected spaceSelection to be .multiple") return } @@ -291,12 +304,13 @@ class SecurityAndPrivacyScreenViewModelTests: XCTestCase { context.send(viewAction: .manageSpaces) try await deferredAction.fulfill() selectedIDs.send([allSpaces[0].id, "unknownSpaceID"]) - XCTAssertEqual(context.desiredSettings.accessType, .spaceMembers(spaceIDs: [allSpaces[0].id, "unknownSpaceID"])) - XCTAssertTrue(context.viewState.shouldShowAccessSectionFooter) - XCTAssertFalse(context.viewState.isSaveDisabled) + #expect(context.desiredSettings.accessType == .spaceMembers(spaceIDs: [allSpaces[0].id, "unknownSpaceID"])) + #expect(context.viewState.shouldShowAccessSectionFooter) + #expect(!context.viewState.isSaveDisabled) } - func testEmptySpaceMembersSelectionEdgeCase() async throws { + @Test + func emptySpaceMembersSelectionEdgeCase() async throws { // Edge case where there is no available joined parents and the room has a restricted join rule. // With no space ids in it setupViewModel(joinedParentSpaces: [], @@ -305,17 +319,18 @@ class SecurityAndPrivacyScreenViewModelTests: XCTestCase { let deferred = deferFulfillment(context.$viewState) { $0.selectableSpacesCount == 0 } try await deferred.fulfill() - XCTAssertTrue(context.viewState.currentSettings.accessType.isSpaceMembers) - XCTAssertTrue(context.viewState.isSaveDisabled) - XCTAssertFalse(context.viewState.isSpaceMembersOptionSelectable) - XCTAssertFalse(context.viewState.shouldShowAccessSectionFooter) + #expect(context.viewState.currentSettings.accessType.isSpaceMembers) + #expect(context.viewState.isSaveDisabled) + #expect(!context.viewState.isSpaceMembersOptionSelectable) + #expect(!context.viewState.shouldShowAccessSectionFooter) guard case .empty = context.viewState.spaceSelection else { - XCTFail("Expected spaceSelection to be .empty") + Issue.record("Expected spaceSelection to be .empty") return } } - func testEmptySpaceMembersSelectionWithJoinedParentEdgeCase() async throws { + @Test + func emptySpaceMembersSelectionWithJoinedParentEdgeCase() async throws { // Edge case where there is one available joined parent but the room has a restricted join rule. // With no space ids in it let singleRoom = [SpaceServiceRoom].mockSingleRoom @@ -325,12 +340,12 @@ class SecurityAndPrivacyScreenViewModelTests: XCTestCase { let deferred = deferFulfillment(context.$viewState) { $0.selectableSpacesCount == 1 } try await deferred.fulfill() - XCTAssertTrue(context.viewState.currentSettings.accessType.isSpaceMembers) - XCTAssertTrue(context.viewState.isSaveDisabled) - XCTAssertTrue(context.viewState.isSpaceMembersOptionSelectable) - XCTAssertTrue(context.viewState.shouldShowAccessSectionFooter) + #expect(context.viewState.currentSettings.accessType.isSpaceMembers) + #expect(context.viewState.isSaveDisabled) + #expect(context.viewState.isSpaceMembersOptionSelectable) + #expect(context.viewState.shouldShowAccessSectionFooter) guard case .multiple = context.viewState.spaceSelection else { - XCTFail("Expected spaceSelection to be .multiple") + Issue.record("Expected spaceSelection to be .multiple") return } @@ -348,11 +363,12 @@ class SecurityAndPrivacyScreenViewModelTests: XCTestCase { try await deferredAction.fulfill() } - func testSave() async throws { + @Test + func save() async throws { setupViewModel(joinedParentSpaces: [], joinRule: .public) // Saving shouldn't dismiss this screen (or trigger any other action). - let deferred = deferFailure(viewModel.actionsPublisher, timeout: 1) { _ in true } + let deferred = deferFailure(viewModel.actionsPublisher, timeout: .seconds(1)) { _ in true } context.desiredSettings.accessType = .inviteOnly context.send(viewAction: .save) @@ -360,15 +376,16 @@ class SecurityAndPrivacyScreenViewModelTests: XCTestCase { try await deferred.fulfill() } - func testCancelWithChangesAndDiscard() async throws { + @Test + func cancelWithChangesAndDiscard() async throws { setupViewModel(joinedParentSpaces: [], joinRule: .public) context.desiredSettings.accessType = .inviteOnly - XCTAssertFalse(context.viewState.isSaveDisabled) - XCTAssertNil(context.alertInfo) + #expect(!context.viewState.isSaveDisabled) + #expect(context.alertInfo == nil) context.send(viewAction: .cancel) - XCTAssertNotNil(context.alertInfo) + #expect(context.alertInfo != nil) let deferred = deferFulfillment(viewModel.actionsPublisher) { switch $0 { @@ -382,15 +399,16 @@ class SecurityAndPrivacyScreenViewModelTests: XCTestCase { try await deferred.fulfill() } - func testCancelWithChangesAndSave() async throws { + @Test + func cancelWithChangesAndSave() async throws { setupViewModel(joinedParentSpaces: [], joinRule: .public) context.desiredSettings.accessType = .inviteOnly - XCTAssertFalse(context.viewState.isSaveDisabled) - XCTAssertNil(context.alertInfo) + #expect(!context.viewState.isSaveDisabled) + #expect(context.alertInfo == nil) context.send(viewAction: .cancel) - XCTAssertNotNil(context.alertInfo) + #expect(context.alertInfo != nil) let deferred = deferFulfillment(viewModel.actionsPublisher) { switch $0 { @@ -404,19 +422,20 @@ class SecurityAndPrivacyScreenViewModelTests: XCTestCase { try await deferred.fulfill() } - func testCancelWithChangesAndSaveWithFailure() async throws { + @Test + func cancelWithChangesAndSaveWithFailure() async throws { setupViewModel(joinedParentSpaces: [], joinRule: .public) roomProxy.updateJoinRuleReturnValue = .failure(.sdkError(RoomProxyMockError.generic)) context.desiredSettings.accessType = .inviteOnly - XCTAssertFalse(context.viewState.isSaveDisabled) - XCTAssertNil(context.alertInfo) + #expect(!context.viewState.isSaveDisabled) + #expect(context.alertInfo == nil) context.send(viewAction: .cancel) - XCTAssertNotNil(context.alertInfo) + #expect(context.alertInfo != nil) // The screen should not be dismissed if a failure occurred. - let deferred = deferFailure(viewModel.actionsPublisher, timeout: 1) { _ in true } + let deferred = deferFailure(viewModel.actionsPublisher, timeout: .seconds(1)) { _ in true } context.alertInfo?.primaryButton.action?() // Save try await deferred.fulfill() } diff --git a/UnitTests/Sources/ServerConfirmationScreenViewModelTests.swift b/UnitTests/Sources/ServerConfirmationScreenViewModelTests.swift index ab826652d..3bf76ffa7 100644 --- a/UnitTests/Sources/ServerConfirmationScreenViewModelTests.swift +++ b/UnitTests/Sources/ServerConfirmationScreenViewModelTests.swift @@ -8,10 +8,12 @@ @testable import ElementX import MatrixRustSDKMocks -import XCTest +import SwiftUI +import Testing +@Suite @MainActor -class ServerConfirmationScreenViewModelTests: XCTestCase { +final class ServerConfirmationScreenViewModelTests { var clientFactory: AuthenticationClientFactoryMock! var client: ClientSDKMock! var service: AuthenticationServiceProtocol! @@ -22,26 +24,27 @@ class ServerConfirmationScreenViewModelTests: XCTestCase { viewModel.context } - override func setUp() { + init() { AppSettings.resetAllSettings() appSettings = AppSettings() // These app settings are kept local to the tests on purpose as if they are registered in the // ServiceLocator, the providers override that we apply will break other tests in the suite. } - override func tearDown() { + deinit { AppSettings.resetAllSettings() } // MARK: - Confirmation mode - func testConfirmLoginWithoutConfiguration() async throws { + @Test + func confirmLoginWithoutConfiguration() async throws { // Given a view model for login using a service that hasn't been configured. setupViewModel(authenticationFlow: .login) - XCTAssertEqual(service.homeserver.value.loginMode, .unknown) - XCTAssertEqual(context.viewState.mode, .confirmation(service.homeserver.value.address)) - XCTAssertEqual(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 0) - XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount, 0) + #expect(service.homeserver.value.loginMode == .unknown) + #expect(context.viewState.mode == .confirmation(service.homeserver.value.address)) + #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0) + #expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0) // When continuing from the confirmation screen. let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithOIDC } @@ -49,23 +52,24 @@ class ServerConfirmationScreenViewModelTests: XCTestCase { try await deferred.fulfill() // Then a call to configure service should be made. - XCTAssertEqual(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1) - XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount, 1) - XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesReceivedArguments?.prompt, .consent) - XCTAssertEqual(service.homeserver.value.loginMode, .oidc(supportsCreatePrompt: true)) + #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1) + #expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 1) + #expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesReceivedArguments?.prompt == .consent) + #expect(service.homeserver.value.loginMode == .oidc(supportsCreatePrompt: true)) } - func testConfirmLoginAfterConfiguration() async throws { + @Test + func confirmLoginAfterConfiguration() async throws { // Given a view model for login using a service that has already been configured (via the server selection screen). setupViewModel(authenticationFlow: .login) guard case .success = await service.configure(for: viewModel.state.homeserverAddress, flow: .login) else { - XCTFail("The configuration should succeed.") + Issue.record("The configuration should succeed.") return } - XCTAssertEqual(service.homeserver.value.loginMode, .oidc(supportsCreatePrompt: true)) - XCTAssertEqual(context.viewState.mode, .confirmation(service.homeserver.value.address)) - XCTAssertEqual(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1) - XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount, 0) + #expect(service.homeserver.value.loginMode == .oidc(supportsCreatePrompt: true)) + #expect(context.viewState.mode == .confirmation(service.homeserver.value.address)) + #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1) + #expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0) // When continuing from the confirmation screen. let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithOIDC } @@ -73,18 +77,19 @@ class ServerConfirmationScreenViewModelTests: XCTestCase { try await deferred.fulfill() // Then the configured homeserver should be used and no additional client should be built. - XCTAssertEqual(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1) - XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount, 1) - XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesReceivedArguments?.prompt, .consent) + #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1) + #expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 1) + #expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesReceivedArguments?.prompt == .consent) } - func testConfirmRegisterWithoutConfiguration() async throws { + @Test + func confirmRegisterWithoutConfiguration() async throws { // Given a view model for registration using a service that hasn't been configured. setupViewModel(authenticationFlow: .register) - XCTAssertEqual(service.homeserver.value.loginMode, .unknown) - XCTAssertEqual(context.viewState.mode, .confirmation(service.homeserver.value.address)) - XCTAssertEqual(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 0) - XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount, 0) + #expect(service.homeserver.value.loginMode == .unknown) + #expect(context.viewState.mode == .confirmation(service.homeserver.value.address)) + #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0) + #expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0) // When continuing from the confirmation screen. let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithOIDC } @@ -92,24 +97,25 @@ class ServerConfirmationScreenViewModelTests: XCTestCase { try await deferred.fulfill() // Then a call to configure service should be made. - XCTAssertEqual(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1) - XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount, 1) + #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1) + #expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 1) // The create prompt is broken: https://github.com/element-hq/matrix-authentication-service/issues/3429 - // XCTAssertEqual(client.urlForOidcOidcConfigurationPromptReceivedArguments?.prompt, .create) - XCTAssertEqual(service.homeserver.value.loginMode, .oidc(supportsCreatePrompt: true)) + // #expect(client.urlForOidcOidcConfigurationPromptReceivedArguments?.prompt == .create) + #expect(service.homeserver.value.loginMode == .oidc(supportsCreatePrompt: true)) } - func testConfirmRegisterAfterConfiguration() async throws { + @Test + func confirmRegisterAfterConfiguration() async throws { // Given a view model for registration using a service that has already been configured (via the server selection screen). setupViewModel(authenticationFlow: .register) guard case .success = await service.configure(for: viewModel.state.homeserverAddress, flow: .register) else { - XCTFail("The configuration should succeed.") + Issue.record("The configuration should succeed.") return } - XCTAssertEqual(service.homeserver.value.loginMode, .oidc(supportsCreatePrompt: true)) - XCTAssertEqual(context.viewState.mode, .confirmation(service.homeserver.value.address)) - XCTAssertEqual(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1) - XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount, 0) + #expect(service.homeserver.value.loginMode == .oidc(supportsCreatePrompt: true)) + #expect(context.viewState.mode == .confirmation(service.homeserver.value.address)) + #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1) + #expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0) // When continuing from the confirmation screen. let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithOIDC } @@ -117,19 +123,20 @@ class ServerConfirmationScreenViewModelTests: XCTestCase { try await deferred.fulfill() // Then the configured homeserver should be used and no additional client should be built. - XCTAssertEqual(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1) + #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1) // The create prompt is broken: https://github.com/element-hq/matrix-authentication-service/issues/3429 - // XCTAssertEqual(client.urlForOidcOidcConfigurationPromptReceivedArguments?.prompt, .create) - XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount, 1) + // #expect(client.urlForOidcOidcConfigurationPromptReceivedArguments?.prompt == .create) + #expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 1) } - func testConfirmPasswordLoginWithoutConfiguration() async throws { + @Test + func confirmPasswordLoginWithoutConfiguration() async throws { // Given a view model for login using a service that hasn't been configured (against a server that doesn't support OIDC). setupViewModel(authenticationFlow: .login, supportsOIDC: false) - XCTAssertEqual(service.homeserver.value.loginMode, .unknown) - XCTAssertEqual(context.viewState.mode, .confirmation(service.homeserver.value.address)) - XCTAssertEqual(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 0) - XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount, 0) + #expect(service.homeserver.value.loginMode == .unknown) + #expect(context.viewState.mode == .confirmation(service.homeserver.value.address)) + #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0) + #expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0) // When continuing from the confirmation screen. let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithPassword } @@ -137,22 +144,23 @@ class ServerConfirmationScreenViewModelTests: XCTestCase { try await deferred.fulfill() // Then a call to configure service should be made, but not for the OIDC URL. - XCTAssertEqual(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1) - XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount, 0) - XCTAssertEqual(service.homeserver.value.loginMode, .password) + #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1) + #expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0) + #expect(service.homeserver.value.loginMode == .password) } - func testConfirmPasswordLoginAfterConfiguration() async throws { + @Test + func confirmPasswordLoginAfterConfiguration() async throws { // Given a view model for login using a service that has already been configured (via the server selection screen). setupViewModel(authenticationFlow: .login, supportsOIDC: false) guard case .success = await service.configure(for: viewModel.state.homeserverAddress, flow: .login) else { - XCTFail("The configuration should succeed.") + Issue.record("The configuration should succeed.") return } - XCTAssertEqual(service.homeserver.value.loginMode, .password) - XCTAssertEqual(context.viewState.mode, .confirmation(service.homeserver.value.address)) - XCTAssertEqual(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1) - XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount, 0) + #expect(service.homeserver.value.loginMode == .password) + #expect(context.viewState.mode == .confirmation(service.homeserver.value.address)) + #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1) + #expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0) // When continuing from the confirmation screen. let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithPassword } @@ -160,17 +168,18 @@ class ServerConfirmationScreenViewModelTests: XCTestCase { try await deferred.fulfill() // Then the configured homeserver should be used and no additional client should be built, nor a call to get the OIDC URL. - XCTAssertEqual(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1) - XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount, 0) + #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1) + #expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0) } - func testRegistrationNotSupportedAlert() async throws { + @Test + func registrationNotSupportedAlert() async throws { // Given a view model for registration using a service that hasn't been configured and the default server doesn't support registration. // Note: We don't currently take the create prompt into account when determining registration support. setupViewModel(authenticationFlow: .register, supportsOIDC: false, supportsOIDCCreatePrompt: false) - XCTAssertEqual(service.homeserver.value.loginMode, .unknown) - XCTAssertEqual(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 0) - XCTAssertNil(context.alertInfo) + #expect(service.homeserver.value.loginMode == .unknown) + #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0) + #expect(context.alertInfo == nil) // When continuing from the confirmation screen. let deferred = deferFulfillment(context.observe(\.alertInfo)) { $0 != nil } @@ -178,16 +187,17 @@ class ServerConfirmationScreenViewModelTests: XCTestCase { try await deferred.fulfill() // Then the configuration should fail with an alert about not supporting registration. - XCTAssertEqual(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1) - XCTAssertEqual(context.alertInfo?.id, .registration) + #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1) + #expect(context.alertInfo?.id == .registration) } - func testLoginNotSupportedAlert() async throws { + @Test + func loginNotSupportedAlert() async throws { // Given a view model for login using a service that hasn't been configured and the default server doesn't support login. setupViewModel(authenticationFlow: .login, supportsOIDC: false, supportsOIDCCreatePrompt: false, supportsPasswordLogin: false) - XCTAssertEqual(service.homeserver.value.loginMode, .unknown) - XCTAssertEqual(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 0) - XCTAssertNil(context.alertInfo) + #expect(service.homeserver.value.loginMode == .unknown) + #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0) + #expect(context.alertInfo == nil) // When continuing from the confirmation screen. let deferred = deferFulfillment(context.observe(\.alertInfo)) { $0 != nil } @@ -195,16 +205,17 @@ class ServerConfirmationScreenViewModelTests: XCTestCase { try await deferred.fulfill() // Then the configuration should fail with an alert about not supporting login. - XCTAssertEqual(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1) - XCTAssertEqual(context.alertInfo?.id, .login) + #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1) + #expect(context.alertInfo?.id == .login) } - func testElementProRequired() async throws { + @Test + func elementProRequired() async throws { // Given a view model for login using a service that hasn't been configured and the default server requires Element Pro. setupViewModel(authenticationFlow: .login, supportsOIDC: false, supportsOIDCCreatePrompt: false, supportsPasswordLogin: false, requiresElementPro: true) - XCTAssertEqual(service.homeserver.value.loginMode, .unknown) - XCTAssertEqual(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 0) - XCTAssertNil(context.alertInfo) + #expect(service.homeserver.value.loginMode == .unknown) + #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0) + #expect(context.alertInfo == nil) // When continuing from the confirmation screen. let deferred = deferFulfillment(context.observe(\.alertInfo)) { $0 != nil } @@ -212,19 +223,20 @@ class ServerConfirmationScreenViewModelTests: XCTestCase { try await deferred.fulfill() // Then the configuration should fail with an alert telling the user to download Element Pro. - XCTAssertEqual(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1) - XCTAssertEqual(context.alertInfo?.id, .elementProRequired(serverName: "matrix.org")) + #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1) + #expect(context.alertInfo?.id == .elementProRequired(serverName: "matrix.org")) } // MARK: - Picker mode - func testPickerWithoutConfiguration() async throws { + @Test + func pickerWithoutConfiguration() async throws { // Given a view model for login using a service that hasn't been configured. setupViewModel(authenticationFlow: .login, restrictedFlow: true) - XCTAssertEqual(service.homeserver.value.loginMode, .unknown) - XCTAssertEqual(context.viewState.mode, .picker(appSettings.accountProviders)) - XCTAssertEqual(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 0) - XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount, 0) + #expect(service.homeserver.value.loginMode == .unknown) + #expect(context.viewState.mode == .picker(appSettings.accountProviders)) + #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0) + #expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0) // When continuing from the confirmation screen. let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithOIDC } @@ -232,23 +244,24 @@ class ServerConfirmationScreenViewModelTests: XCTestCase { try await deferred.fulfill() // Then a call to configure service should be made. - XCTAssertEqual(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1) - XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount, 1) - XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesReceivedArguments?.prompt, .consent) - XCTAssertEqual(service.homeserver.value.loginMode, .oidc(supportsCreatePrompt: true)) + #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1) + #expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 1) + #expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesReceivedArguments?.prompt == .consent) + #expect(service.homeserver.value.loginMode == .oidc(supportsCreatePrompt: true)) } - func testPickerAfterConfiguration() async throws { + @Test + func pickerAfterConfiguration() async throws { // Given a view model for login using a service that has already been configured (via the server selection screen). setupViewModel(authenticationFlow: .login, restrictedFlow: true) guard case .success = await service.configure(for: appSettings.accountProviders[0], flow: .login) else { - XCTFail("The configuration should succeed.") + Issue.record("The configuration should succeed.") return } - XCTAssertEqual(service.homeserver.value.loginMode, .oidc(supportsCreatePrompt: true)) - XCTAssertEqual(context.viewState.mode, .picker(appSettings.accountProviders)) - XCTAssertEqual(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1) - XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount, 0) + #expect(service.homeserver.value.loginMode == .oidc(supportsCreatePrompt: true)) + #expect(context.viewState.mode == .picker(appSettings.accountProviders)) + #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1) + #expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0) // When continuing from the confirmation screen. let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithOIDC } @@ -256,18 +269,19 @@ class ServerConfirmationScreenViewModelTests: XCTestCase { try await deferred.fulfill() // Then the configured homeserver should be used and no additional client should be built. - XCTAssertEqual(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1) - XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount, 1) - XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesReceivedArguments?.prompt, .consent) + #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1) + #expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 1) + #expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesReceivedArguments?.prompt == .consent) } - func testPickerForPasswordLoginWithoutConfiguration() async throws { + @Test + func pickerForPasswordLoginWithoutConfiguration() async throws { // Given a view model for login using a service that hasn't been configured (against a server that doesn't support OIDC). setupViewModel(authenticationFlow: .login, supportsOIDC: false, restrictedFlow: true) - XCTAssertEqual(service.homeserver.value.loginMode, .unknown) - XCTAssertEqual(context.viewState.mode, .picker(appSettings.accountProviders)) - XCTAssertEqual(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 0) - XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount, 0) + #expect(service.homeserver.value.loginMode == .unknown) + #expect(context.viewState.mode == .picker(appSettings.accountProviders)) + #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0) + #expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0) // When continuing from the confirmation screen. let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithPassword } @@ -275,22 +289,23 @@ class ServerConfirmationScreenViewModelTests: XCTestCase { try await deferred.fulfill() // Then a call to configure service should be made, but not for the OIDC URL. - XCTAssertEqual(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1) - XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount, 0) - XCTAssertEqual(service.homeserver.value.loginMode, .password) + #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1) + #expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0) + #expect(service.homeserver.value.loginMode == .password) } - func testPickerForPasswordLoginAfterConfiguration() async throws { + @Test + func pickerForPasswordLoginAfterConfiguration() async throws { // Given a view model for login using a service that has already been configured (via the server selection screen). setupViewModel(authenticationFlow: .login, supportsOIDC: false, restrictedFlow: true) guard case .success = await service.configure(for: appSettings.accountProviders[0], flow: .login) else { - XCTFail("The configuration should succeed.") + Issue.record("The configuration should succeed.") return } - XCTAssertEqual(service.homeserver.value.loginMode, .password) - XCTAssertEqual(context.viewState.mode, .picker(appSettings.accountProviders)) - XCTAssertEqual(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1) - XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount, 0) + #expect(service.homeserver.value.loginMode == .password) + #expect(context.viewState.mode == .picker(appSettings.accountProviders)) + #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1) + #expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0) // When continuing from the confirmation screen. let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithPassword } @@ -298,8 +313,8 @@ class ServerConfirmationScreenViewModelTests: XCTestCase { try await deferred.fulfill() // Then the configured homeserver should be used and no additional client should be built, nor a call to get the OIDC URL. - XCTAssertEqual(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1) - XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount, 0) + #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1) + #expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0) } // MARK: - Helpers diff --git a/UnitTests/Sources/SessionVerificationViewModelTests.swift b/UnitTests/Sources/SessionVerificationViewModelTests.swift index 9141baf4d..199d1dc6d 100644 --- a/UnitTests/Sources/SessionVerificationViewModelTests.swift +++ b/UnitTests/Sources/SessionVerificationViewModelTests.swift @@ -8,15 +8,17 @@ import Combine @testable import ElementX -import XCTest +import Foundation +import Testing +@Suite @MainActor -class SessionVerificationViewModelTests: XCTestCase { +struct SessionVerificationViewModelTests { var viewModel: SessionVerificationScreenViewModelProtocol! var context: SessionVerificationViewModelType.Context! var sessionVerificationController: SessionVerificationControllerProxyMock! - override func setUpWithError() throws { + init() throws { sessionVerificationController = SessionVerificationControllerProxyMock.configureMock() viewModel = SessionVerificationScreenViewModel(sessionVerificationControllerProxy: sessionVerificationController, flow: .deviceInitiator, @@ -25,24 +27,26 @@ class SessionVerificationViewModelTests: XCTestCase { context = viewModel.context } - func testRequestVerification() async throws { - XCTAssertEqual(context.viewState.verificationState, .initial) + @Test + func requestVerification() async throws { + #expect(context.viewState.verificationState == .initial) context.send(viewAction: .requestVerification) try await Task.sleep(for: .milliseconds(100)) - XCTAssert(sessionVerificationController.requestDeviceVerificationCallsCount == 1) - XCTAssertEqual(context.viewState.verificationState, .requestingVerification) + #expect(sessionVerificationController.requestDeviceVerificationCallsCount == 1) + #expect(context.viewState.verificationState == .requestingVerification) } - func testVerificationCancellation() async throws { - XCTAssertEqual(context.viewState.verificationState, .initial) + @Test + func verificationCancellation() async throws { + #expect(context.viewState.verificationState == .initial) context.send(viewAction: .requestVerification) viewModel.stop() - XCTAssertEqual(context.viewState.verificationState, .cancelling) + #expect(context.viewState.verificationState == .cancelling) let deferred = deferFulfillment(context.$viewState) { state in state.verificationState == .cancelled @@ -50,114 +54,80 @@ class SessionVerificationViewModelTests: XCTestCase { try await deferred.fulfill() - XCTAssertEqual(context.viewState.verificationState, .cancelled) + #expect(context.viewState.verificationState == .cancelled) context.send(viewAction: .restart) - XCTAssertEqual(context.viewState.verificationState, .initial) + #expect(context.viewState.verificationState == .initial) - XCTAssert(sessionVerificationController.requestDeviceVerificationCallsCount == 1) - XCTAssert(sessionVerificationController.cancelVerificationCallsCount == 1) + #expect(sessionVerificationController.requestDeviceVerificationCallsCount == 1) + #expect(sessionVerificationController.cancelVerificationCallsCount == 1) } - func testReceiveChallenge() { - setupChallengeReceived() + @Test + mutating func receiveChallenge() async throws { + try await setupChallengeReceived() } - func testAcceptChallenge() { - setupChallengeReceived() + @Test + mutating func acceptChallenge() async throws { + try await setupChallengeReceived() - let waitForAcceptance = XCTestExpectation(description: "Wait for acceptance") - - let cancellable = sessionVerificationController.actions - .delay(for: .seconds(0.1), scheduler: DispatchQueue.main) // Allow the view model to process the callback first. - .sink { callback in - switch callback { - case .finished: - waitForAcceptance.fulfill() - default: - XCTFail("Unexpected session verification controller callback") - } + let deferred = deferFulfillment(sessionVerificationController.actions + .delay(for: .seconds(0.1), scheduler: DispatchQueue.main)) { callback in + if case .finished = callback { return true } + return false } - defer { - cancellable.cancel() - } - context.send(viewAction: .accept) - wait(for: [waitForAcceptance], timeout: 10.0) + try await deferred.fulfill() - XCTAssertEqual(context.viewState.verificationState, .verified) - XCTAssert(sessionVerificationController.approveVerificationCallsCount == 1) + #expect(context.viewState.verificationState == .verified) + #expect(sessionVerificationController.approveVerificationCallsCount == 1) } - func testDeclineChallenge() { - setupChallengeReceived() + @Test + mutating func declineChallenge() async throws { + try await setupChallengeReceived() - let expectation = XCTestExpectation(description: "Wait for cancellation") - - let cancellable = sessionVerificationController.actions - .delay(for: .seconds(0.1), scheduler: DispatchQueue.main) // Allow the view model to process the callback first. - .sink { callback in - switch callback { - case .cancelled: - expectation.fulfill() - default: - XCTFail("Unexpected session verification controller callback") - } + let deferred = deferFulfillment(sessionVerificationController.actions + .delay(for: .seconds(0.1), scheduler: DispatchQueue.main)) { callback in + if case .cancelled = callback { return true } + return false } - defer { - cancellable.cancel() - } - context.send(viewAction: .decline) - wait(for: [expectation], timeout: 10.0) + try await deferred.fulfill() - XCTAssertEqual(context.viewState.verificationState, .cancelled) - XCTAssert(sessionVerificationController.declineVerificationCallsCount == 1) + #expect(context.viewState.verificationState == .cancelled) + #expect(sessionVerificationController.declineVerificationCallsCount == 1) } // MARK: - Private - private func setupChallengeReceived() { - let requestAcceptanceExpectation = XCTestExpectation(description: "Wait for request acceptance") - let sasVerificationStartExpectation = XCTestExpectation(description: "Wait for SaS verification start") - let verificationDataReceivalExpectation = XCTestExpectation(description: "Wait for Emoji data") - - let cancellable = sessionVerificationController.actions - .delay(for: .seconds(0.1), scheduler: DispatchQueue.main) // Allow the view model to process the callback first. - .sink { callback in - switch callback { - case .acceptedVerificationRequest: - requestAcceptanceExpectation.fulfill() - case .startedSasVerification: - sasVerificationStartExpectation.fulfill() - case .receivedVerificationData: - verificationDataReceivalExpectation.fulfill() - default: - break + private mutating func setupChallengeReceived() async throws { + let actionsPublisher = sessionVerificationController.actions.delay(for: .seconds(0.1), scheduler: DispatchQueue.main) + let cancellable = actionsPublisher + .sink { [context] action in + if case .acceptedVerificationRequest = action { + context?.send(viewAction: .startSasVerification) } } - defer { - cancellable.cancel() - } - + let deferred = deferFulfillment(actionsPublisher, + keyPath: \.self, + transitionValues: [.acceptedVerificationRequest, + .startedSasVerification, + .receivedVerificationData(SessionVerificationControllerProxyMock.emojis)]) context.send(viewAction: .requestVerification) - wait(for: [requestAcceptanceExpectation], timeout: 10.0) - XCTAssertEqual(context.viewState.verificationState, .verificationRequestAccepted) + try await deferred.fulfill() - context.send(viewAction: .startSasVerification) - wait(for: [sasVerificationStartExpectation], timeout: 10.0) - XCTAssertEqual(context.viewState.verificationState, .sasVerificationStarted) + #expect(context.viewState.verificationState == .showingChallenge(emojis: SessionVerificationControllerProxyMock.emojis)) + #expect(sessionVerificationController.requestDeviceVerificationCallsCount == 1) + #expect(sessionVerificationController.startSasVerificationCallsCount == 1) - wait(for: [verificationDataReceivalExpectation], timeout: 10.0) - XCTAssertEqual(context.viewState.verificationState, .showingChallenge(emojis: SessionVerificationControllerProxyMock.emojis)) - - XCTAssert(sessionVerificationController.requestDeviceVerificationCallsCount == 1) - XCTAssert(sessionVerificationController.startSasVerificationCallsCount == 1) + cancellable.cancel() } } diff --git a/UnitTests/Sources/SpaceScreenViewModelTests.swift b/UnitTests/Sources/SpaceScreenViewModelTests.swift index d4d915d88..b5b655cdd 100644 --- a/UnitTests/Sources/SpaceScreenViewModelTests.swift +++ b/UnitTests/Sources/SpaceScreenViewModelTests.swift @@ -10,10 +10,11 @@ import Combine @testable import ElementX import MatrixRustSDK import MatrixRustSDKMocks -import XCTest +import Testing +@Suite @MainActor -class SpaceScreenViewModelTests: XCTestCase { +struct SpaceScreenViewModelTests { var spaceRoomListProxy: SpaceRoomListProxyMock! var spaceServiceProxy: SpaceServiceProxyMock! let mockSpaceRooms = [SpaceServiceRoom].mockSpaceList @@ -27,23 +28,25 @@ class SpaceScreenViewModelTests: XCTestCase { viewModel.context } - func testInitialState() { + @Test + mutating func initialState() { setupViewModel() - XCTAssertEqual(context.viewState.paginationState, .idle) - XCTAssertTrue(context.viewState.rooms.isEmpty) - XCTAssertFalse(spaceRoomListProxy.paginateCalled) + #expect(context.viewState.paginationState == .idle) + #expect(context.viewState.rooms.isEmpty) + #expect(!spaceRoomListProxy.paginateCalled) } - func testSinglePagination() async throws { + @Test + mutating func singlePagination() async throws { // Given a space screen view model for a space with a single paginations worth of children. let response = mockSpaceRooms.prefix(3) setupViewModel(paginationResponses: [Array(response)]) - XCTAssertEqual(context.viewState.paginationState, .idle) - XCTAssertTrue(context.viewState.rooms.isEmpty) - XCTAssertFalse(spaceRoomListProxy.paginateCalled) - XCTAssertFalse(response.isEmpty, "There should be some test rooms.") + #expect(context.viewState.paginationState == .idle) + #expect(context.viewState.rooms.isEmpty) + #expect(!spaceRoomListProxy.paginateCalled) + #expect(!response.isEmpty, "There should be some test rooms.") // When the pagination is triggered. var deferred = deferFulfillment(spaceRoomListProxy.paginationStatePublisher) { $0 == .loading } @@ -51,30 +54,31 @@ class SpaceScreenViewModelTests: XCTestCase { try await deferred.fulfill() // Then the screen should show a paginating indicator. - XCTAssertEqual(context.viewState.paginationState, .paginating) - XCTAssertEqual(spaceRoomListProxy.paginateCallsCount, 1) + #expect(context.viewState.paginationState == .paginating) + #expect(spaceRoomListProxy.paginateCallsCount == 1) // When waiting for the pagination to finish. deferred = deferFulfillment(spaceRoomListProxy.paginationStatePublisher) { $0 == .idle(endReached: true) } try await deferred.fulfill() // Then no more pagination requests should be made the the space rooms should be populated. - XCTAssertEqual(context.viewState.paginationState, .endReached) - XCTAssertEqual(spaceRoomListProxy.paginateCallsCount, 1) - XCTAssertEqual(context.viewState.rooms.map(\.id), response.map(\.id)) + #expect(context.viewState.paginationState == .endReached) + #expect(spaceRoomListProxy.paginateCallsCount == 1) + #expect(context.viewState.rooms.map(\.id) == response.map(\.id)) } - func testMultiplePaginations() async throws { + @Test + mutating func multiplePaginations() async throws { // Given a space screen view model for a space with two distinct paginations worth of children. let response1 = mockSpaceRooms.prefix(3) let response2 = mockSpaceRooms.suffix(mockSpaceRooms.count - 3) setupViewModel(paginationResponses: [Array(response1), Array(response2)]) - XCTAssertEqual(context.viewState.paginationState, .idle) - XCTAssertTrue(context.viewState.rooms.isEmpty) - XCTAssertFalse(spaceRoomListProxy.paginateCalled) - XCTAssertFalse(response1.isEmpty, "There should be some test rooms.") - XCTAssertFalse(response2.isEmpty, "There should be more test rooms.") + #expect(context.viewState.paginationState == .idle) + #expect(context.viewState.rooms.isEmpty) + #expect(!spaceRoomListProxy.paginateCalled) + #expect(!response1.isEmpty, "There should be some test rooms.") + #expect(!response2.isEmpty, "There should be more test rooms.") // When the pagination is triggered. let deferredIsPaginating = deferFulfillment(context.observe(\.viewState.paginationState), transitionValues: [.paginating, .idle, .paginating, .endReached]) @@ -88,15 +92,16 @@ class SpaceScreenViewModelTests: XCTestCase { try await deferredIsPaginating.fulfill() try await deferredState.fulfill() - XCTAssertEqual(context.viewState.paginationState, .endReached) - XCTAssertEqual(spaceRoomListProxy.paginateCallsCount, 2) - XCTAssertEqual(context.viewState.rooms.map(\.id), mockSpaceRooms.map(\.id)) + #expect(context.viewState.paginationState == .endReached) + #expect(spaceRoomListProxy.paginateCallsCount == 2) + #expect(context.viewState.rooms.map(\.id) == mockSpaceRooms.map(\.id)) } - func testSelectingSpace() async throws { + @Test + mutating func selectingSpace() async throws { setupViewModel() - let selectedSpace = try XCTUnwrap(mockSpaceRooms.first { $0.isSpace && $0.state == .joined }, "There should be a space to select.") + let selectedSpace = try #require(mockSpaceRooms.first { $0.isSpace && $0.state == .joined }, "There should be a space to select.") let deferred = deferFulfillment(viewModel.actionsPublisher) { _ in true } viewModel.context.send(viewAction: .spaceAction(.select(selectedSpace))) let action = try await deferred.fulfill() @@ -105,14 +110,15 @@ class SpaceScreenViewModelTests: XCTestCase { case .selectSpace(let spaceRoomListProxy) where spaceRoomListProxy.id == selectedSpace.id: break default: - XCTFail("The action should select the space.") + Issue.record("The action should select the space.") } } - func testSelectingUnjoinedSpace() async throws { + @Test + mutating func selectingUnjoinedSpace() async throws { setupViewModel() - let selectedSpace = try XCTUnwrap(mockSpaceRooms.first { $0.isSpace && $0.state != .joined }, "There should be a space to select.") + let selectedSpace = try #require(mockSpaceRooms.first { $0.isSpace && $0.state != .joined }, "There should be a space to select.") let deferred = deferFulfillment(viewModel.actionsPublisher) { _ in true } viewModel.context.send(viewAction: .spaceAction(.select(selectedSpace))) let action = try await deferred.fulfill() @@ -121,14 +127,15 @@ class SpaceScreenViewModelTests: XCTestCase { case .selectUnjoinedSpace(let spaceServiceRoom) where spaceServiceRoom.id == selectedSpace.id: break default: - XCTFail("The action should select the space.") + Issue.record("The action should select the space.") } } - func testSelectingRoom() async throws { + @Test + mutating func selectingRoom() async throws { setupViewModel() - let selectedRoom = try XCTUnwrap(mockSpaceRooms.first { !$0.isSpace }, "There should be a room to select.") + let selectedRoom = try #require(mockSpaceRooms.first { !$0.isSpace }, "There should be a room to select.") let deferred = deferFulfillment(viewModel.actionsPublisher) { _ in true } viewModel.context.send(viewAction: .spaceAction(.select(selectedRoom))) let action = try await deferred.fulfill() @@ -137,106 +144,109 @@ class SpaceScreenViewModelTests: XCTestCase { case .selectRoom(let roomID) where roomID == selectedRoom.id: break default: - XCTFail("The action should select the room.") + Issue.record("The action should select the room.") } } - func testJoiningSpace() async throws { + @Test + mutating func joiningSpace() async throws { setupViewModel() - let selectedSpace = try XCTUnwrap(mockSpaceRooms.first { $0.isSpace && $0.state != .joined }, "There should be a space to select.") + let selectedSpace = try #require(mockSpaceRooms.first { $0.isSpace && $0.state != .joined }, "There should be a space to select.") - let expectation = XCTestExpectation(description: "Join room") - clientProxy.joinRoomViaClosure = { _, _ in - expectation.fulfill() - return .success(()) - } let deferredState = deferFulfillment(viewModel.context.observe(\.viewState.joiningRoomIDs), transitionValues: [[selectedSpace.id], []]) - viewModel.context.send(viewAction: .spaceAction(.join(selectedSpace))) - - await fulfillment(of: [expectation]) - try await deferredState.fulfill() + try await confirmation("Join room") { confirm in + clientProxy.joinRoomViaClosure = { _, _ in + confirm() + return .success(()) + } + viewModel.context.send(viewAction: .spaceAction(.join(selectedSpace))) + try await deferredState.fulfill() + } } - func testJoiningRoom() async throws { + @Test + mutating func joiningRoom() async throws { setupViewModel() - let selectedRoom = try XCTUnwrap(mockSpaceRooms.first { !$0.isSpace }, "There should be a room to select.") + let selectedRoom = try #require(mockSpaceRooms.first { !$0.isSpace }, "There should be a room to select.") - let expectation = XCTestExpectation(description: "Join room") - clientProxy.joinRoomViaClosure = { _, _ in - expectation.fulfill() - return .success(()) - } let deferredState = deferFulfillment(viewModel.context.observe(\.viewState.joiningRoomIDs), transitionValues: [[selectedRoom.id], []]) - viewModel.context.send(viewAction: .spaceAction(.join(selectedRoom))) - - await fulfillment(of: [expectation]) - try await deferredState.fulfill() + try await confirmation("Join room") { confirm in + clientProxy.joinRoomViaClosure = { _, _ in + confirm() + return .success(()) + } + viewModel.context.send(viewAction: .spaceAction(.join(selectedRoom))) + try await deferredState.fulfill() + } } - func testManageRoomsWithoutRemoving() throws { + @Test + mutating func manageRoomsWithoutRemoving() throws { setupViewModel(initialSpaceRooms: mockSpaceRooms) - XCTAssertEqual(context.viewState.editMode, .inactive) - XCTAssertTrue(context.viewState.editModeSelectedIDs.isEmpty) - XCTAssertTrue(context.viewState.visibleRooms.contains { $0.isSpace }) + #expect(context.viewState.editMode == .inactive) + #expect(context.viewState.editModeSelectedIDs.isEmpty) + #expect(context.viewState.visibleRooms.contains { $0.isSpace }) context.send(viewAction: .manageChildren) - XCTAssertEqual(context.viewState.editMode, .transient, "Managing rooms should enable edit mode.") - XCTAssertTrue(context.viewState.editModeSelectedIDs.isEmpty, "No rooms should be selected to begin with.") - XCTAssertFalse(context.viewState.visibleRooms.contains { $0.isSpace }, "Spaces should be filtered out when managing rooms.") + #expect(context.viewState.editMode == .transient, "Managing rooms should enable edit mode.") + #expect(context.viewState.editModeSelectedIDs.isEmpty, "No rooms should be selected to begin with.") + #expect(!context.viewState.visibleRooms.contains { $0.isSpace }, "Spaces should be filtered out when managing rooms.") - let selectedRoom = try XCTUnwrap(mockSpaceRooms.first { !$0.isSpace }, "There should be a room to select.") - XCTAssertFalse(context.viewState.isSpaceIDSelected(selectedRoom.id)) + let selectedRoom = try #require(mockSpaceRooms.first { !$0.isSpace }, "There should be a room to select.") + #expect(!context.viewState.isSpaceIDSelected(selectedRoom.id)) context.send(viewAction: .spaceAction(.select(selectedRoom))) - XCTAssertEqual(context.viewState.editModeSelectedIDs.count, 1, "The selected room should be included.") - XCTAssertTrue(context.viewState.isSpaceIDSelected(selectedRoom.id), "The room should be selected.") + #expect(context.viewState.editModeSelectedIDs.count == 1, "The selected room should be included.") + #expect(context.viewState.isSpaceIDSelected(selectedRoom.id), "The room should be selected.") context.send(viewAction: .finishManagingChildren) - XCTAssertEqual(context.viewState.editMode, .inactive, "Cancelling should disable edit mode.") - XCTAssertTrue(context.viewState.editModeSelectedIDs.isEmpty, "Cancelling should clear all selected rooms.") - XCTAssertTrue(context.viewState.visibleRooms.contains { $0.isSpace }, "Cancelling should restore the hidden spaces.") + #expect(context.viewState.editMode == .inactive, "Cancelling should disable edit mode.") + #expect(context.viewState.editModeSelectedIDs.isEmpty, "Cancelling should clear all selected rooms.") + #expect(context.viewState.visibleRooms.contains { $0.isSpace }, "Cancelling should restore the hidden spaces.") - XCTAssertFalse(spaceServiceProxy.removeChildFromCalled, "There should be no attempt to remove children when cancelling.") + #expect(!spaceServiceProxy.removeChildFromCalled, "There should be no attempt to remove children when cancelling.") } - func testManageRoomsRemovingChildren() async throws { + @Test + mutating func manageRoomsRemovingChildren() async throws { setupViewModel(initialSpaceRooms: mockSpaceRooms) - XCTAssertEqual(context.viewState.editMode, .inactive) - XCTAssertTrue(context.viewState.editModeSelectedIDs.isEmpty) - XCTAssertTrue(context.viewState.visibleRooms.contains { $0.isSpace }) + #expect(context.viewState.editMode == .inactive) + #expect(context.viewState.editModeSelectedIDs.isEmpty) + #expect(context.viewState.visibleRooms.contains { $0.isSpace }) context.send(viewAction: .manageChildren) - XCTAssertEqual(context.viewState.editMode, .transient, "Managing rooms should enable edit mode.") - XCTAssertTrue(context.viewState.editModeSelectedIDs.isEmpty, "No rooms should be selected to begin with.") - XCTAssertFalse(context.viewState.visibleRooms.contains { $0.isSpace }, "Spaces should be filtered out when managing rooms.") + #expect(context.viewState.editMode == .transient, "Managing rooms should enable edit mode.") + #expect(context.viewState.editModeSelectedIDs.isEmpty, "No rooms should be selected to begin with.") + #expect(!context.viewState.visibleRooms.contains { $0.isSpace }, "Spaces should be filtered out when managing rooms.") - let firstRoom = try XCTUnwrap(mockSpaceRooms.first { !$0.isSpace }, "There should be a room to select.") - let lastRoom = try XCTUnwrap(mockSpaceRooms.last { !$0.isSpace }, "There should be a room to select.") - XCTAssertNotEqual(firstRoom.id, lastRoom.id, "There should be more than one room in the list.") + let firstRoom = try #require(mockSpaceRooms.first { !$0.isSpace }, "There should be a room to select.") + let lastRoom = try #require(mockSpaceRooms.last { !$0.isSpace }, "There should be a room to select.") + #expect(firstRoom.id != lastRoom.id, "There should be more than one room in the list.") context.send(viewAction: .spaceAction(.select(firstRoom))) context.send(viewAction: .spaceAction(.select(lastRoom))) - XCTAssertEqual(context.viewState.editModeSelectedIDs.count, 2, "The selected rooms should be included.") + #expect(context.viewState.editModeSelectedIDs.count == 2, "The selected rooms should be included.") context.send(viewAction: .removeSelectedChildren) - XCTAssertTrue(context.isPresentingRemoveChildrenConfirmation, "A confirmation prompt should be shown before removing children.") - XCTAssertFalse(spaceServiceProxy.removeChildFromCalled, "There should be no attempt to remove children before confirming.") + #expect(context.isPresentingRemoveChildrenConfirmation, "A confirmation prompt should be shown before removing children.") + #expect(!spaceServiceProxy.removeChildFromCalled, "There should be no attempt to remove children before confirming.") let deferred = deferFulfillment(context.observe(\.viewState.editMode)) { $0 == .inactive } context.send(viewAction: .confirmRemoveSelectedChildren) try await deferred.fulfill() - XCTAssertFalse(context.isPresentingRemoveChildrenConfirmation, "Confirming should dismiss the confirmation prompt.") - XCTAssertEqual(context.viewState.editMode, .inactive, "Confirming should disable edit mode when done.") - XCTAssertTrue(context.viewState.editModeSelectedIDs.isEmpty, "Confirming should clear all selected rooms when done.") - XCTAssertTrue(context.viewState.visibleRooms.contains { $0.isSpace }, "Confirming should restore the hidden spaces when done.") + #expect(!context.isPresentingRemoveChildrenConfirmation, "Confirming should dismiss the confirmation prompt.") + #expect(context.viewState.editMode == .inactive, "Confirming should disable edit mode when done.") + #expect(context.viewState.editModeSelectedIDs.isEmpty, "Confirming should clear all selected rooms when done.") + #expect(context.viewState.visibleRooms.contains { $0.isSpace }, "Confirming should restore the hidden spaces when done.") - XCTAssertEqual(spaceServiceProxy.removeChildFromCallsCount, 2, "Each selected room should have been removed.") - XCTAssertTrue(spaceRoomListProxy.resetCalled, "The room list should be reset to pick up the changes.") + #expect(spaceServiceProxy.removeChildFromCallsCount == 2, "Each selected room should have been removed.") + #expect(spaceRoomListProxy.resetCalled, "The room list should be reset to pick up the changes.") } - func testManageRoomsRemovingChildrenWithFailure() async throws { + @Test + mutating func manageRoomsRemovingChildrenWithFailure() async throws { setupViewModel(initialSpaceRooms: mockSpaceRooms) context.send(viewAction: .manageChildren) @@ -245,10 +255,10 @@ class SpaceScreenViewModelTests: XCTestCase { } context.send(viewAction: .removeSelectedChildren) - XCTAssertEqual(context.viewState.editMode, .transient, "Managing rooms should enable edit mode.") - XCTAssertEqual(context.viewState.visibleRooms.count, 3, "There should be 3 rooms to begin with.") - XCTAssertEqual(context.viewState.editModeSelectedIDs.count, 3, "All of the visible rooms should be selected.") - XCTAssertTrue(context.isPresentingRemoveChildrenConfirmation, "A confirmation prompt should be shown before removing children.") + #expect(context.viewState.editMode == .transient, "Managing rooms should enable edit mode.") + #expect(context.viewState.visibleRooms.count == 3, "There should be 3 rooms to begin with.") + #expect(context.viewState.editModeSelectedIDs.count == 3, "All of the visible rooms should be selected.") + #expect(context.isPresentingRemoveChildrenConfirmation, "A confirmation prompt should be shown before removing children.") let successfulIDs = context.viewState.editModeSelectedIDs.prefix(1) spaceServiceProxy.removeChildFromClosure = { childID, _ in @@ -260,54 +270,55 @@ class SpaceScreenViewModelTests: XCTestCase { } let deferred = deferFulfillment(context.observe(\.viewState.visibleRooms.count)) { $0 == 2 } - let deferredFailure = deferFailure(context.observe(\.viewState.editMode), timeout: 1) { $0 == .inactive } + let deferredFailure = deferFailure(context.observe(\.viewState.editMode), timeout: .seconds(1)) { $0 == .inactive } context.send(viewAction: .confirmRemoveSelectedChildren) try await deferred.fulfill() try await deferredFailure.fulfill() - XCTAssertEqual(context.viewState.editMode, .transient, "The screen should remain in edit mode.") - XCTAssertEqual(context.viewState.visibleRooms.count, 2, "The removed rooms should no longer be listed for selection.") - XCTAssertEqual(context.viewState.editModeSelectedIDs.count, 2, "The removed rooms should no longer be selected.") + #expect(context.viewState.editMode == .transient, "The screen should remain in edit mode.") + #expect(context.viewState.visibleRooms.count == 2, "The removed rooms should no longer be listed for selection.") + #expect(context.viewState.editModeSelectedIDs.count == 2, "The removed rooms should no longer be selected.") - XCTAssertEqual(spaceServiceProxy.removeChildFromCallsCount, 2, "Each selected room should have been removed.") - XCTAssertFalse(spaceRoomListProxy.resetCalled, "The room list should be reset to pick up the changes.") + #expect(spaceServiceProxy.removeChildFromCallsCount == 2, "Each selected room should have been removed.") + #expect(!spaceRoomListProxy.resetCalled, "The room list should be reset to pick up the changes.") } - func testLeavingSpace() async throws { + @Test + mutating func leavingSpace() async throws { setupViewModel() - XCTAssertNil(context.leaveSpaceViewModel) + #expect(context.leaveSpaceViewModel == nil) let deferredHandle = deferFulfillment(context.observe(\.leaveSpaceViewModel)) { $0 != nil } context.send(viewAction: .leaveSpace) try await deferredHandle.fulfill() - XCTAssertNotNil(context.leaveSpaceViewModel, "The leave action should show the leave view.") + #expect(context.leaveSpaceViewModel != nil, "The leave action should show the leave view.") - let leaveSpaceViewModel = try XCTUnwrap(context.leaveSpaceViewModel) - let handle = try XCTUnwrap(context.leaveSpaceViewModel?.state.leaveHandle) + let leaveSpaceViewModel = try #require(context.leaveSpaceViewModel) + let handle = try #require(context.leaveSpaceViewModel?.state.leaveHandle) let selectedCount = handle.selectedCount - let firstSelectedRoom = try XCTUnwrap(handle.rooms.first { $0.isSelected }) - XCTAssertGreaterThan(selectedCount, 0, "The leave view should have selected rooms to begin with") + let firstSelectedRoom = try #require(handle.rooms.first { $0.isSelected }) + #expect(selectedCount > 0, "The leave view should have selected rooms to begin with") leaveSpaceViewModel.context.send(viewAction: .deselectAll) - XCTAssertEqual(handle.selectedCount, 0, "Deselecting all should result in no selected rooms.") + #expect(handle.selectedCount == 0, "Deselecting all should result in no selected rooms.") leaveSpaceViewModel.context.send(viewAction: .toggleRoom(roomID: firstSelectedRoom.spaceServiceRoom.id)) - XCTAssertEqual(handle.selectedCount, 1, "Toggling a room should result in 1 selected room") + #expect(handle.selectedCount == 1, "Toggling a room should result in 1 selected room") // Confirming the leave should leave the selected room and then the space. let deferredAction = deferFulfillment(viewModel.actionsPublisher) { $0.isLeftSpace } leaveSpaceViewModel.context.send(viewAction: .confirmLeaveSpace) try await deferredAction.fulfill() - XCTAssertNil(context.leaveSpaceViewModel) - XCTAssertTrue(rustLeaveHandle.leaveRoomIdsCalled) - XCTAssertEqual(rustLeaveHandle.leaveRoomIdsReceivedRoomIds, - [firstSelectedRoom.spaceServiceRoom.id, spaceRoomListProxy.id], - "Confirming the leave should first leave the selected room and then the space.") + #expect(context.leaveSpaceViewModel == nil) + #expect(rustLeaveHandle.leaveRoomIdsCalled) + #expect(rustLeaveHandle.leaveRoomIdsReceivedRoomIds == + [firstSelectedRoom.spaceServiceRoom.id, spaceRoomListProxy.id], + "Confirming the leave should first leave the selected room and then the space.") } // MARK: - Helpers - private func setupViewModel(initialSpaceRooms: [SpaceServiceRoom] = [], paginationResponses: [[SpaceServiceRoom]] = []) { + private mutating func setupViewModel(initialSpaceRooms: [SpaceServiceRoom] = [], paginationResponses: [[SpaceServiceRoom]] = []) { spaceRoomListProxy = SpaceRoomListProxyMock(.init(spaceServiceRoom: SpaceServiceRoom.mock(isSpace: true), initialSpaceRooms: initialSpaceRooms, paginationStateSubject: paginationStateSubject, diff --git a/UnitTests/Sources/TestUtilities/DeferredFulfillment.swift b/UnitTests/Sources/TestUtilities/DeferredFulfillment.swift index 7f8320673..ca5a41600 100644 --- a/UnitTests/Sources/TestUtilities/DeferredFulfillment.swift +++ b/UnitTests/Sources/TestUtilities/DeferredFulfillment.swift @@ -133,7 +133,7 @@ func deferFulfillment(_ asyncSequence: any AsyncSequence, defer { group.cancelAll() } - return try #require(try await group.next()) + return try #require(await group.next()) } } } diff --git a/UnitTests/Sources/TimelineMediaPreviewDataSourceTests.swift b/UnitTests/Sources/TimelineMediaPreviewDataSourceTests.swift index 4a4889896..1b38051a1 100644 --- a/UnitTests/Sources/TimelineMediaPreviewDataSourceTests.swift +++ b/UnitTests/Sources/TimelineMediaPreviewDataSourceTests.swift @@ -8,10 +8,11 @@ @testable import ElementX import QuickLook -import XCTest +import Testing +@Suite @MainActor -class TimelineMediaPreviewDataSourceTests: XCTestCase { +struct TimelineMediaPreviewDataSourceTests { var initialMediaItems: [EventBasedMessageTimelineItemProtocol]! var initialMediaViewStates: [RoomTimelineItemViewState]! let initialItemIndex = 2 @@ -19,82 +20,67 @@ class TimelineMediaPreviewDataSourceTests: XCTestCase { var initialPadding = 100 let previewController = QLPreviewController() - override func setUp() { + init() { initialMediaItems = newChunk() initialMediaViewStates = initialMediaItems.map { RoomTimelineItemViewState(item: $0, groupStyle: .single) } } - func testInitialItems() throws -> TimelineMediaPreviewDataSource { - // Given a data source built with the initial items. - let dataSource = TimelineMediaPreviewDataSource(itemViewStates: initialMediaViewStates, - initialItem: initialMediaItems[initialItemIndex], - initialPadding: initialPadding, - paginationState: .initial) - - // When the preview controller displays the data. - let previewItemCount = dataSource.numberOfPreviewItems(in: previewController) - let displayedItem = try XCTUnwrap(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media, - "A preview item should be found.") - - // Then the preview controller should be showing the initial item and the data source should reflect this. - XCTAssertEqual(dataSource.initialItemIndex, initialItemIndex + initialPadding, "The initial item index should be padded for the preview controller.") - XCTAssertEqual(displayedItem.id, initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The displayed item should be the initial item.") - XCTAssertEqual(dataSource.currentMediaItemID, initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The current item should also be the initial item.") - - XCTAssertEqual(dataSource.previewItems.count, initialMediaViewStates.count, "The initial count of preview items should be correct.") - XCTAssertEqual(previewItemCount, initialMediaViewStates.count + (2 * initialPadding), "The initial item count should be padded for the preview controller.") - - return dataSource + @Test + func initialItems() throws { + try assertInitialDataSource() } - func testCurrentUpdateItem() throws { + @Test + func currentUpdateItem() throws { // Given a data source built with the initial items. let dataSource = TimelineMediaPreviewDataSource(itemViewStates: initialMediaViewStates, initialItem: initialMediaItems[initialItemIndex], paginationState: .initial) // When a different item is displayed. - let previewItem = try XCTUnwrap(dataSource.previewController(previewController, previewItemAt: 1 + initialPadding) as? TimelineMediaPreviewItem.Media, - "A preview item should be found.") + let previewItem = try #require(dataSource.previewController(previewController, previewItemAt: 1 + initialPadding) as? TimelineMediaPreviewItem.Media, + "A preview item should be found.") dataSource.updateCurrentItem(.media(previewItem)) // Then the data source should reflect the change of item. - XCTAssertEqual(dataSource.currentMediaItemID, previewItem.id, "The displayed item should be the initial item.") + #expect(dataSource.currentMediaItemID == previewItem.id, "The displayed item should be the initial item.") // When a loading item is displayed. guard let loadingItem = dataSource.previewController(previewController, previewItemAt: initialPadding - 1) as? TimelineMediaPreviewItem.Loading else { - XCTFail("A loading item should be be returned.") + Issue.record("A loading item should be be returned.") return } dataSource.updateCurrentItem(.loading(loadingItem)) // Then the data source should show a loading item - XCTAssertEqual(dataSource.currentItem, .loading(loadingItem), "The displayed item should be the loading item.") + #expect(dataSource.currentItem == .loading(loadingItem), "The displayed item should be the loading item.") } - func testUpdatedItems() async throws { + @Test + func updatedItems() async throws { // Given a data source built with the initial items. - let dataSource = try testInitialItems() + let dataSource = try assertInitialDataSource() // When one of the items changes but no pagination has occurred. - let deferred = deferFailure(dataSource.previewItemsPaginationPublisher, timeout: 1) { _ in true } + let deferred = deferFailure(dataSource.previewItemsPaginationPublisher, timeout: .seconds(1)) { _ in true } dataSource.updatePreviewItems(itemViewStates: initialMediaViewStates) // Then no pagination should be detected and none of the data should have changed. try await deferred.fulfill() let previewItemCount = dataSource.numberOfPreviewItems(in: previewController) - let displayedItem = try XCTUnwrap(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media) - XCTAssertEqual(displayedItem.id, initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The displayed item should not change.") - XCTAssertEqual(dataSource.currentMediaItemID, initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The current item should not change.") + let displayedItem = try #require(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media) + #expect(displayedItem.id == initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The displayed item should not change.") + #expect(dataSource.currentMediaItemID == initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The current item should not change.") - XCTAssertEqual(dataSource.previewItems.count, initialMediaViewStates.count, "The number of items should not change.") - XCTAssertEqual(previewItemCount, initialMediaViewStates.count + (2 * initialPadding), "The padded number of items should not change.") + #expect(dataSource.previewItems.count == initialMediaViewStates.count, "The number of items should not change.") + #expect(previewItemCount == initialMediaViewStates.count + (2 * initialPadding), "The padded number of items should not change.") } - func testPagination() async throws { + @Test + func pagination() async throws { // Given a data source built with the initial items. - let dataSource = try testInitialItems() + let dataSource = try assertInitialDataSource() // When more items are loaded in a back pagination. var deferred = deferFulfillment(dataSource.previewItemsPaginationPublisher) { _ in true } @@ -104,13 +90,13 @@ class TimelineMediaPreviewDataSourceTests: XCTestCase { // Then the new items should be added but the displayed item should not change or move in the array. try await deferred.fulfill() - XCTAssertEqual(dataSource.previewItems.count, newViewStates.count, "The new items should be added.") + #expect(dataSource.previewItems.count == newViewStates.count, "The new items should be added.") var previewItemCount = dataSource.numberOfPreviewItems(in: previewController) - var displayedItem = try XCTUnwrap(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media) - XCTAssertEqual(displayedItem.id, initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The displayed item should not change.") - XCTAssertEqual(dataSource.currentMediaItemID, initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The current item should not change.") - XCTAssertEqual(previewItemCount, initialMediaViewStates.count + (2 * initialPadding), "The number of items should not change") + var displayedItem = try #require(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media) + #expect(displayedItem.id == initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The displayed item should not change.") + #expect(dataSource.currentMediaItemID == initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The current item should not change.") + #expect(previewItemCount == initialMediaViewStates.count + (2 * initialPadding), "The number of items should not change") // When more items are loaded in a forward pagination or sync. deferred = deferFulfillment(dataSource.previewItemsPaginationPublisher) { _ in true } @@ -120,36 +106,37 @@ class TimelineMediaPreviewDataSourceTests: XCTestCase { // Then the new items should be added but the displayed item should not change or move in the array. try await deferred.fulfill() - XCTAssertEqual(dataSource.previewItems.count, newViewStates.count, "The new items should be added.") + #expect(dataSource.previewItems.count == newViewStates.count, "The new items should be added.") previewItemCount = dataSource.numberOfPreviewItems(in: previewController) - displayedItem = try XCTUnwrap(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media) - XCTAssertEqual(displayedItem.id, initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The displayed item should not change.") - XCTAssertEqual(dataSource.currentMediaItemID, initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The current item should not change.") - XCTAssertEqual(previewItemCount, initialMediaViewStates.count + (2 * initialPadding), "The number of items should not change") + displayedItem = try #require(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media) + #expect(displayedItem.id == initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The displayed item should not change.") + #expect(dataSource.currentMediaItemID == initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The current item should not change.") + #expect(previewItemCount == initialMediaViewStates.count + (2 * initialPadding), "The number of items should not change") } - func testPaginationLimits() async throws { + @Test + mutating func paginationLimits() async throws { // Given a data source with a small amount of padding remaining. initialPadding = 2 - let dataSource = try testInitialItems() + let dataSource = try assertInitialDataSource() // When paginating backwards by more than the available padding. var deferred = deferFulfillment(dataSource.previewItemsPaginationPublisher) { _ in true } let backPaginationChunk = newChunk().map { RoomTimelineItemViewState(item: $0, groupStyle: .single) } var newViewStates = backPaginationChunk + initialMediaViewStates - XCTAssertTrue(newViewStates.count > initialPadding) + #expect(newViewStates.count > initialPadding) dataSource.updatePreviewItems(itemViewStates: newViewStates) // Then all the items should be added but the preview-able count shouldn't grow and displayed item should not change or move. try await deferred.fulfill() - XCTAssertEqual(dataSource.previewItems.count, newViewStates.count, "The new items should be added.") + #expect(dataSource.previewItems.count == newViewStates.count, "The new items should be added.") var previewItemCount = dataSource.numberOfPreviewItems(in: previewController) - var displayedItem = try XCTUnwrap(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media) - XCTAssertEqual(displayedItem.id, initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The displayed item should not change.") - XCTAssertEqual(dataSource.currentMediaItemID, initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The current item should not change.") - XCTAssertEqual(previewItemCount, initialMediaViewStates.count + (2 * initialPadding), "The number of items should not change") + var displayedItem = try #require(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media) + #expect(displayedItem.id == initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The displayed item should not change.") + #expect(dataSource.currentMediaItemID == initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The current item should not change.") + #expect(previewItemCount == initialMediaViewStates.count + (2 * initialPadding), "The number of items should not change") // When paginating forwards by more than the available padding. deferred = deferFulfillment(dataSource.previewItemsPaginationPublisher) { _ in true } @@ -159,16 +146,17 @@ class TimelineMediaPreviewDataSourceTests: XCTestCase { // Then all the items should be added but the preview-able count shouldn't grow and displayed item should not change or move. try await deferred.fulfill() - XCTAssertEqual(dataSource.previewItems.count, newViewStates.count, "The new items should be added.") + #expect(dataSource.previewItems.count == newViewStates.count, "The new items should be added.") previewItemCount = dataSource.numberOfPreviewItems(in: previewController) - displayedItem = try XCTUnwrap(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media) - XCTAssertEqual(displayedItem.id, initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The displayed item should not change.") - XCTAssertEqual(dataSource.currentMediaItemID, initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The current item should not change.") - XCTAssertEqual(previewItemCount, initialMediaViewStates.count + (2 * initialPadding), "The number of items should not change") + displayedItem = try #require(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media) + #expect(displayedItem.id == initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The displayed item should not change.") + #expect(dataSource.currentMediaItemID == initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The current item should not change.") + #expect(previewItemCount == initialMediaViewStates.count + (2 * initialPadding), "The number of items should not change") } - func testEmptyTimeline() async throws { + @Test + func emptyTimeline() async throws { // Given a data source built with no timeline items loaded. let initialItem = initialMediaItems[initialItemIndex] let dataSource = TimelineMediaPreviewDataSource(itemViewStates: [], @@ -178,16 +166,16 @@ class TimelineMediaPreviewDataSourceTests: XCTestCase { // When the preview controller displays the data. var previewItemCount = dataSource.numberOfPreviewItems(in: previewController) - var displayedItem = try XCTUnwrap(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media, - "A preview item should be found.") + var displayedItem = try #require(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media, + "A preview item should be found.") // Then the preview controller should always show the initial item. - XCTAssertEqual(dataSource.previewItems.count, 1, "The initial item should be in the preview items array.") - XCTAssertEqual(previewItemCount, 1 + (2 * initialPadding), "The initial item count should be padded for the preview controller.") - XCTAssertEqual(dataSource.initialItemIndex, initialPadding, "The initial item index should be padded for the preview controller.") + #expect(dataSource.previewItems.count == 1, "The initial item should be in the preview items array.") + #expect(previewItemCount == 1 + (2 * initialPadding), "The initial item count should be padded for the preview controller.") + #expect(dataSource.initialItemIndex == initialPadding, "The initial item index should be padded for the preview controller.") - XCTAssertEqual(displayedItem.id, initialItem.id.eventOrTransactionID, "The displayed item should be the initial item.") - XCTAssertEqual(dataSource.currentMediaItemID, initialItem.id.eventOrTransactionID, "The current item should also be the initial item.") + #expect(displayedItem.id == initialItem.id.eventOrTransactionID, "The displayed item should be the initial item.") + #expect(dataSource.currentMediaItemID == initialItem.id.eventOrTransactionID, "The current item should also be the initial item.") // When the timeline loads the initial items. let deferred = deferFulfillment(dataSource.previewItemsPaginationPublisher) { _ in true } @@ -197,18 +185,19 @@ class TimelineMediaPreviewDataSourceTests: XCTestCase { // Then the preview controller should still show the initial item with the other items loaded around it. previewItemCount = dataSource.numberOfPreviewItems(in: previewController) - displayedItem = try XCTUnwrap(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media, - "A preview item should be found.") + displayedItem = try #require(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media, + "A preview item should be found.") - XCTAssertEqual(dataSource.previewItems.count, initialMediaViewStates.count, "The preview items should now be loaded.") - XCTAssertEqual(previewItemCount, 1 + (2 * initialPadding), "The item count should not change as the padding will be reduced.") - XCTAssertEqual(dataSource.initialItemIndex, initialPadding, "The item index should not change.") + #expect(dataSource.previewItems.count == initialMediaViewStates.count, "The preview items should now be loaded.") + #expect(previewItemCount == 1 + (2 * initialPadding), "The item count should not change as the padding will be reduced.") + #expect(dataSource.initialItemIndex == initialPadding, "The item index should not change.") - XCTAssertEqual(displayedItem.id, initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The displayed item should not change.") - XCTAssertEqual(dataSource.currentMediaItemID, initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The current item should not change.") + #expect(displayedItem.id == initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The displayed item should not change.") + #expect(dataSource.currentMediaItemID == initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The current item should not change.") } - func testTimelineUpdateWithoutInitialItem() async throws { + @Test + func timelineUpdateWithoutInitialItem() async throws { // Given a data source built with no timeline items loaded. let initialItem = initialMediaItems[initialItemIndex] let dataSource = TimelineMediaPreviewDataSource(itemViewStates: [], @@ -218,34 +207,34 @@ class TimelineMediaPreviewDataSourceTests: XCTestCase { // When the preview controller displays the data. var previewItemCount = dataSource.numberOfPreviewItems(in: previewController) - var displayedItem = try XCTUnwrap(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media, - "A preview item should be found.") + var displayedItem = try #require(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media, + "A preview item should be found.") // Then the preview controller should always show the initial item. - XCTAssertEqual(dataSource.previewItems.count, 1, "The initial item should be in the preview items array.") - XCTAssertEqual(previewItemCount, 1 + (2 * initialPadding), "The initial item count should be padded for the preview controller.") - XCTAssertEqual(dataSource.initialItemIndex, initialPadding, "The initial item index should be padded for the preview controller.") + #expect(dataSource.previewItems.count == 1, "The initial item should be in the preview items array.") + #expect(previewItemCount == 1 + (2 * initialPadding), "The initial item count should be padded for the preview controller.") + #expect(dataSource.initialItemIndex == initialPadding, "The initial item index should be padded for the preview controller.") - XCTAssertEqual(displayedItem.id, initialItem.id.eventOrTransactionID, "The displayed item should be the initial item.") - XCTAssertEqual(dataSource.currentMediaItemID, initialItem.id.eventOrTransactionID, "The current item should also be the initial item.") + #expect(displayedItem.id == initialItem.id.eventOrTransactionID, "The displayed item should be the initial item.") + #expect(dataSource.currentMediaItemID == initialItem.id.eventOrTransactionID, "The current item should also be the initial item.") // When the timeline loads more items but still doesn't include the initial item. - let failure = deferFailure(dataSource.previewItemsPaginationPublisher, timeout: 1) { _ in true } + let failure = deferFailure(dataSource.previewItemsPaginationPublisher, timeout: .seconds(1)) { _ in true } let loadedItems = newChunk().map { RoomTimelineItemViewState(item: $0, groupStyle: .single) } dataSource.updatePreviewItems(itemViewStates: loadedItems) try await failure.fulfill() // Then the preview controller shouldn't update the available preview items. previewItemCount = dataSource.numberOfPreviewItems(in: previewController) - displayedItem = try XCTUnwrap(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media, - "A preview item should be found.") + displayedItem = try #require(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media, + "A preview item should be found.") - XCTAssertEqual(dataSource.previewItems.count, 1, "No new items should have been added to the array.") - XCTAssertEqual(previewItemCount, 1 + (2 * initialPadding), "The initial item count should not change.") - XCTAssertEqual(dataSource.initialItemIndex, initialPadding, "The initial item index should not change.") + #expect(dataSource.previewItems.count == 1, "No new items should have been added to the array.") + #expect(previewItemCount == 1 + (2 * initialPadding), "The initial item count should not change.") + #expect(dataSource.initialItemIndex == initialPadding, "The initial item index should not change.") - XCTAssertEqual(displayedItem.id, initialItem.id.eventOrTransactionID, "The displayed item should not change.") - XCTAssertEqual(dataSource.currentMediaItemID, initialItem.id.eventOrTransactionID, "The current item not change.") + #expect(displayedItem.id == initialItem.id.eventOrTransactionID, "The displayed item should not change.") + #expect(dataSource.currentMediaItemID == initialItem.id.eventOrTransactionID, "The current item not change.") } // MARK: Helpers @@ -255,6 +244,30 @@ class TimelineMediaPreviewDataSourceTests: XCTestCase { .compactMap { $0 as? EventBasedMessageTimelineItemProtocol } .filter(\.supportsMediaCaption) // Voice messages can't be previewed (and don't support captions). } + + @discardableResult + private func assertInitialDataSource() throws -> TimelineMediaPreviewDataSource { + // Given a data source built with the initial items. + let dataSource = TimelineMediaPreviewDataSource(itemViewStates: initialMediaViewStates, + initialItem: initialMediaItems[initialItemIndex], + initialPadding: initialPadding, + paginationState: .initial) + + // When the preview controller displays the data. + let previewItemCount = dataSource.numberOfPreviewItems(in: previewController) + let displayedItem = try #require(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media, + "A preview item should be found.") + + // Then the preview controller should be showing the initial item and the data source should reflect this. + #expect(dataSource.initialItemIndex == initialItemIndex + initialPadding, "The initial item index should be padded for the preview controller.") + #expect(displayedItem.id == initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The displayed item should be the initial item.") + #expect(dataSource.currentMediaItemID == initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The current item should also be the initial item.") + + #expect(dataSource.previewItems.count == initialMediaViewStates.count, "The initial count of preview items should be correct.") + #expect(previewItemCount == initialMediaViewStates.count + (2 * initialPadding), "The initial item count should be padded for the preview controller.") + + return dataSource + } } private extension TimelineMediaPreviewDataSource { diff --git a/UnitTests/Sources/TimelineMediaPreviewViewModelTests.swift b/UnitTests/Sources/TimelineMediaPreviewViewModelTests.swift index 5c81c4da0..9e41ec8ef 100644 --- a/UnitTests/Sources/TimelineMediaPreviewViewModelTests.swift +++ b/UnitTests/Sources/TimelineMediaPreviewViewModelTests.swift @@ -11,10 +11,11 @@ import Combine import MatrixRustSDK import QuickLook import SwiftUI -import XCTest +import Testing +@Suite @MainActor -class TimelineMediaPreviewViewModelTests: XCTestCase { +struct TimelineMediaPreviewViewModelTests { var viewModel: TimelineMediaPreviewViewModel! var context: TimelineMediaPreviewViewModel.Context { viewModel.context @@ -24,49 +25,52 @@ class TimelineMediaPreviewViewModelTests: XCTestCase { var photoLibraryManager: PhotoLibraryManagerMock! var timelineController: MockTimelineController! - func testLoadingItem() async throws { + @Test + mutating func loadingItem() async throws { // Given a fresh view model. setupViewModel() - XCTAssertFalse(mediaProvider.loadFileFromSourceFilenameCalled) - XCTAssertEqual(context.viewState.currentItem, .media(context.viewState.dataSource.previewItems[0])) - XCTAssertNotNil(context.viewState.currentItemActions) + #expect(!mediaProvider.loadFileFromSourceFilenameCalled) + #expect(context.viewState.currentItem == .media(context.viewState.dataSource.previewItems[0])) + #expect(context.viewState.currentItemActions != nil) // When the preview controller sets the current item. try await loadInitialItem() // Then the view model should load the item and update its view state. - XCTAssertTrue(mediaProvider.loadFileFromSourceFilenameCalled) - XCTAssertEqual(context.viewState.currentItem, .media(context.viewState.dataSource.previewItems[0])) - XCTAssertNotNil(context.viewState.currentItemActions) + #expect(mediaProvider.loadFileFromSourceFilenameCalled) + #expect(context.viewState.currentItem == .media(context.viewState.dataSource.previewItems[0])) + #expect(context.viewState.currentItemActions != nil) } - func testLoadingItemFailure() async throws { + @Test + mutating func loadingItemFailure() async throws { // Given a fresh view model. setupViewModel() guard case let .media(mediaItem) = context.viewState.currentItem else { - XCTFail("There should be a current item") + Issue.record("There should be a current item") return } - XCTAssertFalse(mediaProvider.loadFileFromSourceFilenameCalled) - XCTAssertEqual(mediaItem, context.viewState.dataSource.previewItems[0]) - XCTAssertNil(mediaItem.downloadError) + #expect(!mediaProvider.loadFileFromSourceFilenameCalled) + #expect(mediaItem == context.viewState.dataSource.previewItems[0]) + #expect(mediaItem.downloadError == nil) // When the preview controller sets an item that fails to load. mediaProvider.loadFileFromSourceFilenameClosure = { _, _ in .failure(.failedRetrievingFile) } - let failure = deferFailure(viewModel.state.previewControllerDriver, timeout: 1) { $0.isItemLoaded } + let failure = deferFailure(viewModel.state.previewControllerDriver, timeout: .seconds(1)) { $0.isItemLoaded } context.send(viewAction: .updateCurrentItem(.media(context.viewState.dataSource.previewItems[0]))) try await failure.fulfill() // Then the view model should load the item and update its view state. - XCTAssertTrue(mediaProvider.loadFileFromSourceFilenameCalled) - XCTAssertEqual(mediaItem, context.viewState.dataSource.previewItems[0]) - XCTAssertNotNil(mediaItem.downloadError) + #expect(mediaProvider.loadFileFromSourceFilenameCalled) + #expect(mediaItem == context.viewState.dataSource.previewItems[0]) + #expect(mediaItem.downloadError != nil) } - func testSwipingBetweenItems() async throws { + @Test + mutating func swipingBetweenItems() async throws { // Given a view model with a loaded item. - try await testLoadingItem() + try await loadingItem() // When swiping to another item. let deferred = deferFulfillment(viewModel.state.previewControllerDriver) { $0.isItemLoaded } @@ -74,41 +78,43 @@ class TimelineMediaPreviewViewModelTests: XCTestCase { try await deferred.fulfill() // Then the view model should load the item and update its view state. - XCTAssertEqual(mediaProvider.loadFileFromSourceFilenameCallsCount, 2) - XCTAssertEqual(context.viewState.currentItem, .media(context.viewState.dataSource.previewItems[1])) + #expect(mediaProvider.loadFileFromSourceFilenameCallsCount == 2) + #expect(context.viewState.currentItem == .media(context.viewState.dataSource.previewItems[1])) // When swiping back to the first item. - let failure = deferFailure(viewModel.state.previewControllerDriver, timeout: 1) { $0.isItemLoaded } + let failure = deferFailure(viewModel.state.previewControllerDriver, timeout: .seconds(1)) { $0.isItemLoaded } context.send(viewAction: .updateCurrentItem(.media(context.viewState.dataSource.previewItems[0]))) try await failure.fulfill() // Then the view model should not need to load the item, but should still update its view state. - XCTAssertEqual(mediaProvider.loadFileFromSourceFilenameCallsCount, 2) - XCTAssertEqual(context.viewState.currentItem, .media(context.viewState.dataSource.previewItems[0])) + #expect(mediaProvider.loadFileFromSourceFilenameCallsCount == 2) + #expect(context.viewState.currentItem == .media(context.viewState.dataSource.previewItems[0])) } - func testLoadingMoreItems() async throws { + @Test + mutating func loadingMoreItems() async throws { // Given a view model with a loaded item. - try await testLoadingItem() - XCTAssertEqual(timelineController.paginateBackwardsCallCount, 0) + try await loadingItem() + #expect(timelineController.paginateBackwardsCallCount == 0) // When swiping to a "loading more" item and there are more media items to load. timelineController.paginationState = .init(backward: .idle, forward: .endReached) timelineController.backPaginationResponses.append(RoomTimelineItemFixtures.mediaChunk) - let failure = deferFailure(viewModel.state.previewControllerDriver, timeout: 1) { $0.isItemLoaded } + let failure = deferFailure(viewModel.state.previewControllerDriver, timeout: .seconds(1)) { $0.isItemLoaded } context.send(viewAction: .updateCurrentItem(.loading(.paginatingBackwards))) try await failure.fulfill() // Then there should no longer be a media preview and instead of loading any media, a pagination request should be made. - XCTAssertEqual(mediaProvider.loadFileFromSourceFilenameCallsCount, 1) - XCTAssertEqual(context.viewState.currentItem, .loading(.paginatingBackwards)) // Note: This item only changes when the preview controller handles the new items. - XCTAssertEqual(timelineController.paginateBackwardsCallCount, 1) + #expect(mediaProvider.loadFileFromSourceFilenameCallsCount == 1) + #expect(context.viewState.currentItem == .loading(.paginatingBackwards)) // Note: This item only changes when the preview controller handles the new items. + #expect(timelineController.paginateBackwardsCallCount == 1) } - func testPagination() async throws { + @Test + mutating func pagination() async throws { // Given a view model with a loaded item. - try await testLoadingItem() - XCTAssertEqual(context.viewState.dataSource.previewItems.count, 3) + try await loadingItem() + #expect(context.viewState.dataSource.previewItems.count == 3) // When more items are added via a back pagination. let deferred = deferFulfillment(context.viewState.dataSource.previewItemsPaginationPublisher) { _ in true } @@ -118,22 +124,23 @@ class TimelineMediaPreviewViewModelTests: XCTestCase { // And the preview controller attempts to update the current item (now at a new index in the array but it hasn't changed in the data source). mediaProvider.loadFileFromSourceFilenameClosure = { _, _ in .failure(.failedRetrievingFile) } - let failure = deferFailure(viewModel.state.previewControllerDriver, timeout: 1) { $0.isItemLoaded } + let failure = deferFailure(viewModel.state.previewControllerDriver, timeout: .seconds(1)) { $0.isItemLoaded } context.send(viewAction: .updateCurrentItem(.media(context.viewState.dataSource.previewItems[3]))) try await failure.fulfill() // Then the current item shouldn't need to be reloaded. - XCTAssertEqual(context.viewState.dataSource.previewItems.count, 6) - XCTAssertEqual(mediaProvider.loadFileFromSourceFilenameCallsCount, 1) + #expect(context.viewState.dataSource.previewItems.count == 6) + #expect(mediaProvider.loadFileFromSourceFilenameCallsCount == 1) } - func testViewInRoomTimeline() async throws { + @Test + mutating func viewInRoomTimeline() async throws { // Given a view model with a loaded item. - try await testLoadingItem() + try await loadingItem() // When choosing to view the current item in the timeline. guard case let .media(mediaItem) = context.viewState.currentItem else { - XCTFail("There should be a current item.") + Issue.record("There should be a current item.") return } @@ -144,13 +151,14 @@ class TimelineMediaPreviewViewModelTests: XCTestCase { try await deferred.fulfill() } - func testRedactConfirmation() async throws { + @Test + mutating func redactConfirmation() async throws { // Given a view model with a loaded item. - try await testLoadingItem() - XCTAssertNil(context.redactConfirmationItem) - XCTAssertFalse(timelineController.redactCalled) + try await loadingItem() + #expect(context.redactConfirmationItem == nil) + #expect(!timelineController.redactCalled) guard case let .media(mediaItem) = context.viewState.currentItem else { - XCTFail("There should be a current item.") + Issue.record("There should be a current item.") return } @@ -161,17 +169,17 @@ class TimelineMediaPreviewViewModelTests: XCTestCase { // Then the details sheet should be presented. let action = try await deferredDriver.fulfill() guard case let .showItemDetails(mediaDetailsItem) = action else { - XCTFail("The action should include the media item.") + Issue.record("The action should include the media item.") return } - XCTAssertEqual(.media(mediaDetailsItem), context.viewState.currentItem) + #expect(.media(mediaDetailsItem) == context.viewState.currentItem) // When choosing to redact the item. context.send(viewAction: .menuAction(.redact, item: mediaItem)) // Then the confirmation sheet should be presented. - XCTAssertEqual(context.redactConfirmationItem, mediaItem) - XCTAssertFalse(timelineController.redactCalled) + #expect(context.redactConfirmationItem == mediaItem) + #expect(!timelineController.redactCalled) // When confirming the redaction. let deferred = deferFulfillment(viewModel.actions) { $0 == .dismiss } @@ -179,37 +187,39 @@ class TimelineMediaPreviewViewModelTests: XCTestCase { // Then the item should be redacted and the view should be dismissed. try await deferred.fulfill() - XCTAssertTrue(timelineController.redactCalled) + #expect(timelineController.redactCalled) } - func testSaveImage() async throws { + @Test + mutating func saveImage() async throws { // Given a view model with a loaded image. - try await testLoadingItem() + try await loadingItem() guard case let .media(mediaItem) = context.viewState.currentItem else { - XCTFail("There should be a current item") + Issue.record("There should be a current item") return } - XCTAssertEqual(mediaItem.contentType, "JPEG image") + #expect(mediaItem.contentType == "JPEG image") // When choosing to save the image. context.send(viewAction: .menuAction(.save, item: mediaItem)) try await Task.sleep(for: .seconds(0.5)) // Then the image should be saved as a photo to the user's photo library. - XCTAssertTrue(photoLibraryManager.addResourceAtCalled) - XCTAssertEqual(photoLibraryManager.addResourceAtReceivedArguments?.type, .photo) - XCTAssertEqual(photoLibraryManager.addResourceAtReceivedArguments?.url, mediaItem.fileHandle?.url) + #expect(photoLibraryManager.addResourceAtCalled) + #expect(photoLibraryManager.addResourceAtReceivedArguments?.type == .photo) + #expect(photoLibraryManager.addResourceAtReceivedArguments?.url == mediaItem.fileHandle?.url) } - func testSaveImageWithoutAuthorization() async throws { + @Test + mutating func saveImageWithoutAuthorization() async throws { // Given a view model with a loaded image where the user has denied access to the photo library. setupViewModel(photoLibraryAuthorizationDenied: true) try await loadInitialItem() guard case let .media(mediaItem) = context.viewState.currentItem else { - XCTFail("There should be a current item") + Issue.record("There should be a current item") return } - XCTAssertEqual(mediaItem.contentType, "JPEG image") + #expect(mediaItem.contentType == "JPEG image") // When choosing to save the image. let deferred = deferFulfillment(context.viewState.previewControllerDriver) { $0.isAuthorizationRequired } @@ -217,38 +227,40 @@ class TimelineMediaPreviewViewModelTests: XCTestCase { // Then the user should be prompted to allow access. try await deferred.fulfill() - XCTAssertTrue(photoLibraryManager.addResourceAtCalled) + #expect(photoLibraryManager.addResourceAtCalled) } - func testSaveVideo() async throws { + @Test + mutating func saveVideo() async throws { // Given a view model with a loaded video. setupViewModel(initialItemIndex: 1) try await loadInitialItem() guard case let .media(mediaItem) = context.viewState.currentItem else { - XCTFail("There should be a current item") + Issue.record("There should be a current item") return } - XCTAssertEqual(mediaItem.contentType, "MPEG-4 movie") + #expect(mediaItem.contentType == "MPEG-4 movie") // When choosing to save the video. context.send(viewAction: .menuAction(.save, item: mediaItem)) try await Task.sleep(for: .seconds(0.5)) // Then the video should be saved as a video in the user's photo library. - XCTAssertTrue(photoLibraryManager.addResourceAtCalled) - XCTAssertEqual(photoLibraryManager.addResourceAtReceivedArguments?.type, .video) - XCTAssertEqual(photoLibraryManager.addResourceAtReceivedArguments?.url, mediaItem.fileHandle?.url) + #expect(photoLibraryManager.addResourceAtCalled) + #expect(photoLibraryManager.addResourceAtReceivedArguments?.type == .video) + #expect(photoLibraryManager.addResourceAtReceivedArguments?.url == mediaItem.fileHandle?.url) } - func testSaveFile() async throws { + @Test + mutating func saveFile() async throws { // Given a view model with a loaded file. setupViewModel(initialItemIndex: 2) try await loadInitialItem() guard case let .media(mediaItem) = context.viewState.currentItem else { - XCTFail("There should be a current item") + Issue.record("There should be a current item") return } - XCTAssertEqual(mediaItem.contentType, "PDF document") + #expect(mediaItem.contentType == "PDF document") // When choosing to save the file. let deferred = deferFulfillment(context.viewState.previewControllerDriver) { $0.isExportFile } @@ -256,13 +268,13 @@ class TimelineMediaPreviewViewModelTests: XCTestCase { let exportAction = try await deferred.fulfill() guard case let .exportFile(file) = exportAction else { - XCTFail("Unexpected action") + Issue.record("Unexpected action") return } // Then the binding should be set for the user to export the file to their specified location. - XCTAssertFalse(photoLibraryManager.addResourceAtCalled) - XCTAssertEqual(file.url, mediaItem.fileHandle?.url) + #expect(!photoLibraryManager.addResourceAtCalled) + #expect(file.url == mediaItem.fileHandle?.url) } // MARK: - Helpers @@ -272,14 +284,14 @@ class TimelineMediaPreviewViewModelTests: XCTestCase { let initialItem = context.viewState.dataSource.previewController(QLPreviewController(), previewItemAt: context.viewState.dataSource.initialItemIndex) guard let initialPreviewItem = initialItem as? TimelineMediaPreviewItem.Media else { - XCTFail("The initial item should be a media preview.") + Issue.record("The initial item should be a media preview.") return } context.send(viewAction: .updateCurrentItem(.media(initialPreviewItem))) try await deferred.fulfill() } - private func setupViewModel(initialItemIndex: Int = 0, photoLibraryAuthorizationDenied: Bool = false) { + private mutating func setupViewModel(initialItemIndex: Int = 0, photoLibraryAuthorizationDenied: Bool = false) { let initialItems = makeItems() timelineController = MockTimelineController(timelineKind: .media(.mediaFilesScreen)) timelineController.timelineItems = initialItems diff --git a/UnitTests/Sources/TimelineViewModelTests.swift b/UnitTests/Sources/TimelineViewModelTests.swift index da3f7f6ac..4348344df 100644 --- a/UnitTests/Sources/TimelineViewModelTests.swift +++ b/UnitTests/Sources/TimelineViewModelTests.swift @@ -8,27 +8,30 @@ import Combine @testable import ElementX +import Foundation import MatrixRustSDK -import XCTest +import Testing +@Suite @MainActor -class TimelineViewModelTests: XCTestCase { +final class TimelineViewModelTests { var userIndicatorControllerMock: UserIndicatorControllerMock! var cancellables = Set() - override func setUp() async throws { + init() async throws { AppSettings.resetAllSettings() cancellables.removeAll() userIndicatorControllerMock = UserIndicatorControllerMock.default } - override func tearDown() async throws { + deinit { userIndicatorControllerMock = nil } // MARK: - Message Grouping - func testMessageGrouping() { + @Test + func messageGrouping() { // Given 3 messages from Bob. let items = [ TextRoomTimelineItem(text: "Message 1", @@ -45,12 +48,13 @@ class TimelineViewModelTests: XCTestCase { let viewModel = makeViewModel(timelineController: timelineController) // Then the messages should be grouped together. - XCTAssertEqual(viewModel.state.timelineState.itemViewStates[0].groupStyle, .first, "Nothing should prevent the first message from being grouped.") - XCTAssertEqual(viewModel.state.timelineState.itemViewStates[1].groupStyle, .middle, "Nothing should prevent the middle message from being grouped.") - XCTAssertEqual(viewModel.state.timelineState.itemViewStates[2].groupStyle, .last, "Nothing should prevent the last message from being grouped.") + #expect(viewModel.state.timelineState.itemViewStates[0].groupStyle == .first, "Nothing should prevent the first message from being grouped.") + #expect(viewModel.state.timelineState.itemViewStates[1].groupStyle == .middle, "Nothing should prevent the middle message from being grouped.") + #expect(viewModel.state.timelineState.itemViewStates[2].groupStyle == .last, "Nothing should prevent the last message from being grouped.") } - func testMessageGroupingMultipleSenders() { + @Test + func messageGroupingMultipleSenders() { // Given some interleaved messages from Bob and Alice. let items = [ TextRoomTimelineItem(text: "Message 1", @@ -73,15 +77,16 @@ class TimelineViewModelTests: XCTestCase { let viewModel = makeViewModel(timelineController: timelineController) // Then the messages should be grouped by sender. - XCTAssertEqual(viewModel.state.timelineState.itemViewStates[0].groupStyle, .single, "A message should not be grouped when the sender changes.") - XCTAssertEqual(viewModel.state.timelineState.itemViewStates[1].groupStyle, .single, "A message should not be grouped when the sender changes.") - XCTAssertEqual(viewModel.state.timelineState.itemViewStates[2].groupStyle, .first, "A group should start with a new sender if there are more messages from that sender.") - XCTAssertEqual(viewModel.state.timelineState.itemViewStates[3].groupStyle, .last, "A group should be ended when the sender changes in the next message.") - XCTAssertEqual(viewModel.state.timelineState.itemViewStates[4].groupStyle, .first, "A group should start with a new sender if there are more messages from that sender.") - XCTAssertEqual(viewModel.state.timelineState.itemViewStates[5].groupStyle, .last, "A group should be ended when the sender changes in the next message.") + #expect(viewModel.state.timelineState.itemViewStates[0].groupStyle == .single, "A message should not be grouped when the sender changes.") + #expect(viewModel.state.timelineState.itemViewStates[1].groupStyle == .single, "A message should not be grouped when the sender changes.") + #expect(viewModel.state.timelineState.itemViewStates[2].groupStyle == .first, "A group should start with a new sender if there are more messages from that sender.") + #expect(viewModel.state.timelineState.itemViewStates[3].groupStyle == .last, "A group should be ended when the sender changes in the next message.") + #expect(viewModel.state.timelineState.itemViewStates[4].groupStyle == .first, "A group should start with a new sender if there are more messages from that sender.") + #expect(viewModel.state.timelineState.itemViewStates[5].groupStyle == .last, "A group should be ended when the sender changes in the next message.") } - func testMessageGroupingWithLeadingReactions() { + @Test + func messageGroupingWithLeadingReactions() { // Given 3 messages from Bob where the first message has a reaction. let items = [ TextRoomTimelineItem(text: "Message 1", @@ -99,12 +104,13 @@ class TimelineViewModelTests: XCTestCase { let viewModel = makeViewModel(timelineController: timelineController) // Then the first message should not be grouped but the other two should. - XCTAssertEqual(viewModel.state.timelineState.itemViewStates[0].groupStyle, .single, "When the first message has reactions it should not be grouped.") - XCTAssertEqual(viewModel.state.timelineState.itemViewStates[1].groupStyle, .first, "A new group should be made when the preceding message has reactions.") - XCTAssertEqual(viewModel.state.timelineState.itemViewStates[2].groupStyle, .last, "Nothing should prevent the last message from being grouped.") + #expect(viewModel.state.timelineState.itemViewStates[0].groupStyle == .single, "When the first message has reactions it should not be grouped.") + #expect(viewModel.state.timelineState.itemViewStates[1].groupStyle == .first, "A new group should be made when the preceding message has reactions.") + #expect(viewModel.state.timelineState.itemViewStates[2].groupStyle == .last, "Nothing should prevent the last message from being grouped.") } - func testMessageGroupingWithInnerReactions() { + @Test + func messageGroupingWithInnerReactions() { // Given 3 messages from Bob where the middle message has a reaction. let items = [ TextRoomTimelineItem(text: "Message 1", @@ -122,12 +128,13 @@ class TimelineViewModelTests: XCTestCase { let viewModel = makeViewModel(timelineController: timelineController) // Then the first and second messages should be grouped and the last one should not. - XCTAssertEqual(viewModel.state.timelineState.itemViewStates[0].groupStyle, .first, "Nothing should prevent the first message from being grouped.") - XCTAssertEqual(viewModel.state.timelineState.itemViewStates[1].groupStyle, .last, "When the message has reactions, the group should end here.") - XCTAssertEqual(viewModel.state.timelineState.itemViewStates[2].groupStyle, .single, "The last message should not be grouped when the preceding message has reactions.") + #expect(viewModel.state.timelineState.itemViewStates[0].groupStyle == .first, "Nothing should prevent the first message from being grouped.") + #expect(viewModel.state.timelineState.itemViewStates[1].groupStyle == .last, "When the message has reactions, the group should end here.") + #expect(viewModel.state.timelineState.itemViewStates[2].groupStyle == .single, "The last message should not be grouped when the preceding message has reactions.") } - func testMessageGroupingWithTrailingReactions() { + @Test + func messageGroupingWithTrailingReactions() { // Given 3 messages from Bob where the last message has a reaction. let items = [ TextRoomTimelineItem(text: "Message 1", @@ -145,14 +152,15 @@ class TimelineViewModelTests: XCTestCase { let viewModel = makeViewModel(timelineController: timelineController) // Then the messages should be grouped together. - XCTAssertEqual(viewModel.state.timelineState.itemViewStates[0].groupStyle, .first, "Nothing should prevent the first message from being grouped.") - XCTAssertEqual(viewModel.state.timelineState.itemViewStates[1].groupStyle, .middle, "Nothing should prevent the second message from being grouped.") - XCTAssertEqual(viewModel.state.timelineState.itemViewStates[2].groupStyle, .last, "Reactions on the last message should not prevent it from being grouped.") + #expect(viewModel.state.timelineState.itemViewStates[0].groupStyle == .first, "Nothing should prevent the first message from being grouped.") + #expect(viewModel.state.timelineState.itemViewStates[1].groupStyle == .middle, "Nothing should prevent the second message from being grouped.") + #expect(viewModel.state.timelineState.itemViewStates[2].groupStyle == .last, "Reactions on the last message should not prevent it from being grouped.") } // MARK: - Focussing - func testFocusItem() async throws { + @Test + func focusItem() async throws { // Given a room with 3 items loaded in a live timeline. let items = [TextRoomTimelineItem(eventID: "t1"), TextRoomTimelineItem(eventID: "t2"), @@ -161,9 +169,9 @@ class TimelineViewModelTests: XCTestCase { timelineController.timelineItems = items let viewModel = makeViewModel(timelineController: timelineController) - XCTAssertEqual(timelineController.focusOnEventCallCount, 0) - XCTAssertTrue(viewModel.context.viewState.timelineState.isLive) - XCTAssertNil(viewModel.context.viewState.timelineState.focussedEvent) + #expect(timelineController.focusOnEventCallCount == 0) + #expect(viewModel.context.viewState.timelineState.isLive) + #expect(viewModel.context.viewState.timelineState.focussedEvent == nil) // When focussing on an item that isn't loaded. let deferred = deferFulfillment(viewModel.context.$viewState) { !$0.timelineState.isLive } @@ -171,12 +179,13 @@ class TimelineViewModelTests: XCTestCase { try await deferred.fulfill() // Then a new timeline should be loaded and the room focussed on that event. - XCTAssertEqual(timelineController.focusOnEventCallCount, 1) - XCTAssertFalse(viewModel.context.viewState.timelineState.isLive) - XCTAssertEqual(viewModel.context.viewState.timelineState.focussedEvent, .init(eventID: "t4", appearance: .immediate)) + #expect(timelineController.focusOnEventCallCount == 1) + #expect(!viewModel.context.viewState.timelineState.isLive) + #expect(viewModel.context.viewState.timelineState.focussedEvent == .init(eventID: "t4", appearance: .immediate)) } - func testFocusLoadedItem() async throws { + @Test + func focusLoadedItem() async throws { // Given a room with 3 items loaded in a live timeline. let items = [TextRoomTimelineItem(eventID: "t1"), TextRoomTimelineItem(eventID: "t2"), @@ -185,22 +194,23 @@ class TimelineViewModelTests: XCTestCase { timelineController.timelineItems = items let viewModel = makeViewModel(timelineController: timelineController) - XCTAssertEqual(timelineController.focusOnEventCallCount, 0) - XCTAssertTrue(viewModel.context.viewState.timelineState.isLive) - XCTAssertNil(viewModel.context.viewState.timelineState.focussedEvent) + #expect(timelineController.focusOnEventCallCount == 0) + #expect(viewModel.context.viewState.timelineState.isLive) + #expect(viewModel.context.viewState.timelineState.focussedEvent == nil) // When focussing on a loaded item. - let deferred = deferFailure(viewModel.context.$viewState, timeout: 1) { !$0.timelineState.isLive } + let deferred = deferFailure(viewModel.context.$viewState, timeout: .seconds(1)) { !$0.timelineState.isLive } await viewModel.focusOnEvent(eventID: "t1") try await deferred.fulfill() // Then the timeline should remain live and the item should be focussed. - XCTAssertEqual(timelineController.focusOnEventCallCount, 0) - XCTAssertTrue(viewModel.context.viewState.timelineState.isLive) - XCTAssertEqual(viewModel.context.viewState.timelineState.focussedEvent, .init(eventID: "t1", appearance: .animated)) + #expect(timelineController.focusOnEventCallCount == 0) + #expect(viewModel.context.viewState.timelineState.isLive) + #expect(viewModel.context.viewState.timelineState.focussedEvent == .init(eventID: "t1", appearance: .animated)) } - func testFocusLive() async throws { + @Test + func focusLive() async throws { // Given a room with a non-live timeline focussed on a particular event. let items = [TextRoomTimelineItem(eventID: "t1"), TextRoomTimelineItem(eventID: "t2"), @@ -214,9 +224,9 @@ class TimelineViewModelTests: XCTestCase { await viewModel.focusOnEvent(eventID: "t4") try await deferred.fulfill() - XCTAssertEqual(timelineController.focusLiveCallCount, 0) - XCTAssertFalse(viewModel.context.viewState.timelineState.isLive) - XCTAssertEqual(viewModel.context.viewState.timelineState.focussedEvent, .init(eventID: "t4", appearance: .immediate)) + #expect(timelineController.focusLiveCallCount == 0) + #expect(!viewModel.context.viewState.timelineState.isLive) + #expect(viewModel.context.viewState.timelineState.focussedEvent == .init(eventID: "t4", appearance: .immediate)) // When switching back to a live timeline. deferred = deferFulfillment(viewModel.context.$viewState) { $0.timelineState.isLive } @@ -224,21 +234,23 @@ class TimelineViewModelTests: XCTestCase { try await deferred.fulfill() // Then the timeline should switch back to being live and the event focus should be removed. - XCTAssertEqual(timelineController.focusLiveCallCount, 1) - XCTAssertTrue(viewModel.context.viewState.timelineState.isLive) - XCTAssertNil(viewModel.context.viewState.timelineState.focussedEvent) + #expect(timelineController.focusLiveCallCount == 1) + #expect(viewModel.context.viewState.timelineState.isLive) + #expect(viewModel.context.viewState.timelineState.focussedEvent == nil) } - func testInitialFocusViewState() { + @Test + func initialFocusViewState() { let timelineController = MockTimelineController() let viewModel = makeViewModel(focussedEventID: "t10", timelineController: timelineController) - XCTAssertEqual(viewModel.context.viewState.timelineState.focussedEvent, .init(eventID: "t10", appearance: .immediate)) + #expect(viewModel.context.viewState.timelineState.focussedEvent == .init(eventID: "t10", appearance: .immediate)) } // MARK: - Read Receipts - func testSendReadReceipt() async throws { + @Test + func sendReadReceipt() async throws { // Given a room with only text items in the timeline let items = [TextRoomTimelineItem(eventID: "t1"), TextRoomTimelineItem(eventID: "t2"), @@ -246,17 +258,18 @@ class TimelineViewModelTests: XCTestCase { let (viewModel, _, timelineProxy, _) = readReceiptsConfiguration(with: items) // When sending a read receipt for the last item. - try viewModel.context.send(viewAction: .sendReadReceiptIfNeeded(XCTUnwrap(items.last?.id))) + try viewModel.context.send(viewAction: .sendReadReceiptIfNeeded(#require(items.last?.id))) try await Task.sleep(for: .milliseconds(100)) // Then the receipt should be sent. - XCTAssertEqual(timelineProxy.sendReadReceiptForTypeCalled, true) + #expect(timelineProxy.sendReadReceiptForTypeCalled == true) let arguments = timelineProxy.sendReadReceiptForTypeReceivedArguments - XCTAssertEqual(arguments?.eventID, "t3") - XCTAssertEqual(arguments?.type, .read) + #expect(arguments?.eventID == "t3") + #expect(arguments?.type == .read) } - func testSendReadReceiptWithoutEvents() async throws { + @Test + func sendReadReceiptWithoutEvents() async throws { // Given a room with only virtual items. let items = [SeparatorRoomTimelineItem(uniqueID: .init("v1")), SeparatorRoomTimelineItem(uniqueID: .init("v2")), @@ -264,14 +277,15 @@ class TimelineViewModelTests: XCTestCase { let (viewModel, _, timelineProxy, _) = readReceiptsConfiguration(with: items) // When sending a read receipt for the last item. - try viewModel.context.send(viewAction: .sendReadReceiptIfNeeded(XCTUnwrap(items.last?.id))) + try viewModel.context.send(viewAction: .sendReadReceiptIfNeeded(#require(items.last?.id))) try await Task.sleep(for: .milliseconds(100)) // Then nothing should be sent. - XCTAssertEqual(timelineProxy.sendReadReceiptForTypeCalled, false) + #expect(timelineProxy.sendReadReceiptForTypeCalled == false) } - func testSendReadReceiptVirtualLast() async throws { + @Test + func sendReadReceiptVirtualLast() async throws { // Given a room where the last event is a virtual item. let items: [RoomTimelineItemProtocol] = [TextRoomTimelineItem(eventID: "t1"), TextRoomTimelineItem(eventID: "t2"), @@ -279,7 +293,7 @@ class TimelineViewModelTests: XCTestCase { let (viewModel, _, _, _) = readReceiptsConfiguration(with: items) // When sending a read receipt for the last item. - try viewModel.context.send(viewAction: .sendReadReceiptIfNeeded(XCTUnwrap(items.last?.id))) + try viewModel.context.send(viewAction: .sendReadReceiptIfNeeded(#require(items.last?.id))) try await Task.sleep(for: .milliseconds(100)) } @@ -314,7 +328,8 @@ class TimelineViewModelTests: XCTestCase { return (viewModel, roomProxy, timelineProxy, timelineController) } - func testShowReadReceipts() async throws { + @Test + func showReadReceipts() async throws { let receipts: [ReadReceipt] = [.init(userID: "@alice:matrix.org", formattedTimestamp: "12:00"), .init(userID: "@charlie:matrix.org", formattedTimestamp: "11:00")] // Given 3 messages from Bob where the middle message has a reaction. @@ -346,7 +361,8 @@ class TimelineViewModelTests: XCTestCase { try await deferred.fulfill() } - func testShowManageUserAsAdmin() async throws { + @Test + func showManageUserAsAdmin() async throws { let viewModel = TimelineViewModel(roomProxy: JoinedRoomProxyMock(.init(name: "", members: [RoomMemberProxyMock.mockAdmin, RoomMemberProxyMock.mockAlice], @@ -375,14 +391,15 @@ class TimelineViewModelTests: XCTestCase { viewModel.context.send(viewAction: .tappedOnSenderDetails(sender: .init(with: RoomMemberProxyMock.mockAlice))) try await deferred.fulfill() - XCTAssertEqual(viewModel.context.manageMemberViewModel?.id, RoomMemberProxyMock.mockAlice.userID) - XCTAssertEqual(viewModel.context.manageMemberViewModel?.state.permissions.canBan, true) - XCTAssertEqual(viewModel.context.manageMemberViewModel?.state.permissions.canKick, true) - XCTAssertEqual(viewModel.context.manageMemberViewModel?.state.isKickDisabled, false) - XCTAssertEqual(viewModel.context.manageMemberViewModel?.state.isBanUnbanDisabled, false) + #expect(viewModel.context.manageMemberViewModel?.id == RoomMemberProxyMock.mockAlice.userID) + #expect(viewModel.context.manageMemberViewModel?.state.permissions.canBan == true) + #expect(viewModel.context.manageMemberViewModel?.state.permissions.canKick == true) + #expect(viewModel.context.manageMemberViewModel?.state.isKickDisabled == false) + #expect(viewModel.context.manageMemberViewModel?.state.isBanUnbanDisabled == false) } - func testShowDetailsForAnAdmin() async throws { + @Test + func showDetailsForAnAdmin() async throws { let viewModel = TimelineViewModel(roomProxy: JoinedRoomProxyMock(.init(name: "", members: [RoomMemberProxyMock.mockAdmin, RoomMemberProxyMock.mockAlice], @@ -411,14 +428,15 @@ class TimelineViewModelTests: XCTestCase { viewModel.context.send(viewAction: .tappedOnSenderDetails(sender: .init(with: RoomMemberProxyMock.mockAdmin))) try await deferredState.fulfill() - XCTAssertEqual(viewModel.context.manageMemberViewModel?.state.permissions.canBan, false) - XCTAssertEqual(viewModel.context.manageMemberViewModel?.state.permissions.canKick, false) - XCTAssertEqual(viewModel.context.manageMemberViewModel?.state.isKickDisabled, true) - XCTAssertEqual(viewModel.context.manageMemberViewModel?.state.isBanUnbanDisabled, true) - XCTAssertEqual(viewModel.context.manageMemberViewModel?.id, RoomMemberProxyMock.mockAdmin.userID) + #expect(viewModel.context.manageMemberViewModel?.state.permissions.canBan == false) + #expect(viewModel.context.manageMemberViewModel?.state.permissions.canKick == false) + #expect(viewModel.context.manageMemberViewModel?.state.isKickDisabled == true) + #expect(viewModel.context.manageMemberViewModel?.state.isBanUnbanDisabled == true) + #expect(viewModel.context.manageMemberViewModel?.id == RoomMemberProxyMock.mockAdmin.userID) } - func testShowDetailsForABannedUser() async throws { + @Test + func showDetailsForABannedUser() async throws { let viewModel = TimelineViewModel(roomProxy: JoinedRoomProxyMock(.init(name: "", members: [RoomMemberProxyMock.mockAdmin, RoomMemberProxyMock.mockBanned[0]], @@ -447,17 +465,18 @@ class TimelineViewModelTests: XCTestCase { viewModel.context.send(viewAction: .tappedOnSenderDetails(sender: .init(with: RoomMemberProxyMock.mockBanned[0]))) try await deferredState.fulfill() - XCTAssertEqual(viewModel.context.manageMemberViewModel?.state.permissions.canBan, true) - XCTAssertEqual(viewModel.context.manageMemberViewModel?.state.permissions.canKick, true) - XCTAssertEqual(viewModel.context.manageMemberViewModel?.state.isKickDisabled, true) - XCTAssertEqual(viewModel.context.manageMemberViewModel?.state.isBanUnbanDisabled, false) - XCTAssertEqual(viewModel.context.manageMemberViewModel?.state.isMemberBanned, true) - XCTAssertEqual(viewModel.context.manageMemberViewModel?.id, RoomMemberProxyMock.mockBanned[0].userID) + #expect(viewModel.context.manageMemberViewModel?.state.permissions.canBan == true) + #expect(viewModel.context.manageMemberViewModel?.state.permissions.canKick == true) + #expect(viewModel.context.manageMemberViewModel?.state.isKickDisabled == true) + #expect(viewModel.context.manageMemberViewModel?.state.isBanUnbanDisabled == false) + #expect(viewModel.context.manageMemberViewModel?.state.isMemberBanned == true) + #expect(viewModel.context.manageMemberViewModel?.id == RoomMemberProxyMock.mockBanned[0].userID) } // MARK: - Pins - func testPinnedEvents() async throws { + @Test + func pinnedEvents() async throws { var configuration = JoinedRoomProxyMockConfiguration(name: "", pinnedEventIDs: .init(["test1"])) let roomProxyMock = JoinedRoomProxyMock(configuration) @@ -475,7 +494,7 @@ class TimelineViewModelTests: XCTestCase { emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings), linkMetadataProvider: LinkMetadataProvider(), timelineControllerFactory: TimelineControllerFactoryMock(.init())) - XCTAssertEqual(configuration.pinnedEventIDs, viewModel.context.viewState.pinnedEventIDs) + #expect(configuration.pinnedEventIDs == viewModel.context.viewState.pinnedEventIDs) configuration.pinnedEventIDs = ["test1", "test2"] let deferred = deferFulfillment(viewModel.context.$viewState) { value in @@ -485,7 +504,8 @@ class TimelineViewModelTests: XCTestCase { try await deferred.fulfill() } - func testCanUserPinEvents() async throws { + @Test + func canUserPinEvents() async throws { let configuration = JoinedRoomProxyMockConfiguration(name: "", powerLevelsConfiguration: .init(canUserPin: true)) let roomProxyMock = JoinedRoomProxyMock(configuration) @@ -526,32 +546,34 @@ class TimelineViewModelTests: XCTestCase { // MARK: - Tap Actions - func testTapSendInfoEncryptionAuthentictyDisplaysAlert() { + @Test + func tapSendInfoEncryptionAuthentictyDisplaysAlert() { // Given a room with an event whose authenticity could not be verified let items = [TextRoomTimelineItem(eventID: "t1", encryptionAuthenticity: .verificationViolation(color: .red))] let timelineController = MockTimelineController() timelineController.timelineItems = items let viewModel = makeViewModel(timelineController: timelineController) - XCTAssertNil(viewModel.state.bindings.alertInfo) + #expect(viewModel.state.bindings.alertInfo == nil) viewModel.process(viewAction: .itemSendInfoTapped(itemID: items[0].id)) - XCTAssertEqual(viewModel.state.bindings.alertInfo?.title, "Encrypted by a previously-verified user.") + #expect(viewModel.state.bindings.alertInfo?.title == "Encrypted by a previously-verified user.") } - func testTapSendInfoEncryptionForwarderDisplaysAlert() { + @Test + func tapSendInfoEncryptionForwarderDisplaysAlert() { // Given a room with an event whose key was forwarded let items = [TextRoomTimelineItem(eventID: "t1", keyForwarder: .test)] let timelineController = MockTimelineController() timelineController.timelineItems = items let viewModel = makeViewModel(timelineController: timelineController) - XCTAssertNil(viewModel.state.bindings.alertInfo) + #expect(viewModel.state.bindings.alertInfo == nil) viewModel.process(viewAction: .itemSendInfoTapped(itemID: items[0].id)) - XCTAssertEqual(viewModel.state.bindings.alertInfo?.title, "alice (@alice:matrix.org) shared this message since you were not in the room when it was sent.") + #expect(viewModel.state.bindings.alertInfo?.title == "alice (@alice:matrix.org) shared this message since you were not in the room when it was sent.") } // MARK: - Helpers diff --git a/UnitTests/Sources/VoiceMessageRecorderTests.swift b/UnitTests/Sources/VoiceMessageRecorderTests.swift index 99d1028a1..226d8e9c5 100644 --- a/UnitTests/Sources/VoiceMessageRecorderTests.swift +++ b/UnitTests/Sources/VoiceMessageRecorderTests.swift @@ -9,10 +9,11 @@ import Combine @testable import ElementX import Foundation -import XCTest +import Testing +@Suite @MainActor -class VoiceMessageRecorderTests: XCTestCase { +struct VoiceMessageRecorderTests { private var voiceMessageRecorder: VoiceMessageRecorder! private var audioRecorder: AudioRecorderMock! @@ -33,7 +34,7 @@ class VoiceMessageRecorderTests: XCTestCase { private let recordingURL = URL("/some/url") - override func setUp() async throws { + init() async throws { audioRecorder = AudioRecorderMock() audioRecorder.underlyingCurrentTime = 0 audioRecorder.averagePowerReturnValue = 0 @@ -61,7 +62,7 @@ class VoiceMessageRecorderTests: XCTestCase { let deferred = deferFulfillment(voiceMessageRecorder.actions) { action in switch action { - case .didStopRecording(_, let url) where url == self.recordingURL: + case .didStopRecording(_, let url) where url == recordingURL: return true default: return false @@ -71,141 +72,153 @@ class VoiceMessageRecorderTests: XCTestCase { try await deferred.fulfill() } - func testRecordingURL() { + @Test + func recorderRecordingURL() { audioRecorder.audioFileURL = recordingURL - XCTAssertEqual(voiceMessageRecorder.recordingURL, recordingURL) + #expect(voiceMessageRecorder.recordingURL == recordingURL) } - func testRecordingDuration() { + @Test + func recorderRecordingDuration() { audioRecorder.currentTime = 10.3 - XCTAssertEqual(voiceMessageRecorder.recordingDuration, 10.3) + #expect(voiceMessageRecorder.recordingDuration == 10.3) } - func testStartRecording() async { + @Test + func startRecording() async { _ = await voiceMessageRecorder.startRecording() - XCTAssert(audioRecorder.recordAudioFileURLCalled) + #expect(audioRecorder.recordAudioFileURLCalled) } - func testStopRecording() async { + @Test + func stopRecording() async { _ = await voiceMessageRecorder.stopRecording() // Internal audio recorder must have been stopped - XCTAssert(audioRecorder.stopRecordingCalled) + #expect(audioRecorder.stopRecordingCalled) } - func testCancelRecording() async { + @Test + func cancelRecording() async { await voiceMessageRecorder.cancelRecording() // Internal audio recorder must have been stopped - XCTAssert(audioRecorder.stopRecordingCalled) + #expect(audioRecorder.stopRecordingCalled) // The recording audio file must have been deleted - XCTAssert(audioRecorder.deleteRecordingCalled) + #expect(audioRecorder.deleteRecordingCalled) } - func testDeleteRecording() async { + @Test + func deleteRecording() async { await voiceMessageRecorder.deleteRecording() // The recording audio file must have been deleted - XCTAssert(audioRecorder.deleteRecordingCalled) + #expect(audioRecorder.deleteRecordingCalled) } - func testStartPlaybackNoPreview() async { + @Test + func startPlaybackNoPreview() async { guard case .failure(.previewNotAvailable) = await voiceMessageRecorder.startPlayback() else { - XCTFail("An error is expected") + Issue.record("An error is expected") return } } - func testStartPlayback() async throws { + @Test + func startPlayback() async throws { try await setRecordingComplete() guard case .success = await voiceMessageRecorder.startPlayback() else { - XCTFail("Playback should start") + Issue.record("Playback should start") return } - XCTAssertEqual(voiceMessageRecorder.previewAudioPlayerState?.isAttached, true) - XCTAssert(audioPlayer.loadSourceURLPlaybackURLAutoplayCalled) - XCTAssertEqual(audioPlayer.loadSourceURLPlaybackURLAutoplayReceivedArguments?.sourceURL, recordingURL) - XCTAssertEqual(audioPlayer.loadSourceURLPlaybackURLAutoplayReceivedArguments?.playbackURL, recordingURL) - XCTAssertEqual(audioPlayer.loadSourceURLPlaybackURLAutoplayReceivedArguments?.autoplay, true) - XCTAssertFalse(audioPlayer.playCalled) + #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) } - func testPausePlayback() async throws { + @Test + func pausePlayback() async throws { try await setRecordingComplete() _ = await voiceMessageRecorder.startPlayback() - XCTAssertEqual(voiceMessageRecorder.previewAudioPlayerState?.isAttached, true) + #expect(voiceMessageRecorder.previewAudioPlayerState?.isAttached == true) voiceMessageRecorder.pausePlayback() - XCTAssert(audioPlayer.pauseCalled) + #expect(audioPlayer.pauseCalled) } - func testResumePlayback() async throws { + @Test + func resumePlayback() async throws { try await setRecordingComplete() audioPlayer.playbackURL = recordingURL guard case .success = await voiceMessageRecorder.startPlayback() else { - XCTFail("Playback should start") + Issue.record("Playback should start") return } - XCTAssertEqual(voiceMessageRecorder.previewAudioPlayerState?.isAttached, true) + #expect(voiceMessageRecorder.previewAudioPlayerState?.isAttached == true) // The media must not have been reloaded - XCTAssertFalse(audioPlayer.loadSourceURLPlaybackURLAutoplayCalled) - XCTAssertTrue(audioPlayer.playCalled) + #expect(!audioPlayer.loadSourceURLPlaybackURLAutoplayCalled) + #expect(audioPlayer.playCalled) } - func testStopPlayback() async throws { + @Test + func stopPlayback() async throws { try await setRecordingComplete() _ = await voiceMessageRecorder.startPlayback() - XCTAssertEqual(voiceMessageRecorder.previewAudioPlayerState?.isAttached, true) + #expect(voiceMessageRecorder.previewAudioPlayerState?.isAttached == true) await voiceMessageRecorder.stopPlayback() - XCTAssertEqual(voiceMessageRecorder.previewAudioPlayerState?.isAttached, false) - XCTAssert(audioPlayer.stopCalled) + #expect(voiceMessageRecorder.previewAudioPlayerState?.isAttached == false) + #expect(audioPlayer.stopCalled) } - func testSeekPlayback() async throws { + @Test + func seekPlayback() async throws { try await setRecordingComplete() _ = await voiceMessageRecorder.startPlayback() - XCTAssertEqual(voiceMessageRecorder.previewAudioPlayerState?.isAttached, true) + #expect(voiceMessageRecorder.previewAudioPlayerState?.isAttached == true) await voiceMessageRecorder.seekPlayback(to: 0.4) - XCTAssertEqual(audioPlayer.seekToReceivedProgress, 0.4) + #expect(audioPlayer.seekToReceivedProgress == 0.4) } - func testBuildRecordedWaveform() async { + @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 { - XCTFail("An error is expected") + Issue.record("An error is expected") return } - guard let audioFileURL = Bundle(for: Self.self).url(forResource: "test_audio", withExtension: "mp3") else { - XCTFail("Test audio file is missing") - 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 { - XCTFail("A waveform is expected") + Issue.record("A waveform is expected") return } - XCTAssert(!data.isEmpty) + #expect(!data.isEmpty) } - func testSendVoiceMessage_NoRecordingFile() async { + @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 { - XCTFail("An error is expected") + Issue.record("An error is expected") return } } - func testSendVoiceMessage_ConversionError() async { + @Test + func sendVoiceMessage_ConversionError() async { audioRecorder.audioFileURL = recordingURL // If the converter returns an error audioConverter.convertToOpusOggSourceURLDestinationURLThrowableError = AudioConverterError.conversionFailed(nil) @@ -213,16 +226,14 @@ class VoiceMessageRecorderTests: XCTestCase { let timelineController = MockTimelineController() guard case .failure(.failedSendingVoiceMessage) = await voiceMessageRecorder.sendVoiceMessage(timelineController: timelineController, audioConverter: audioConverter) else { - XCTFail("An error is expected") + Issue.record("An error is expected") return } } - func testSendVoiceMessage_InvalidFile() async { - guard let audioFileURL = Bundle(for: Self.self).url(forResource: "test_voice_message", withExtension: "m4a") else { - XCTFail("Test audio file is missing") - 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) @@ -233,16 +244,14 @@ class VoiceMessageRecorderTests: XCTestCase { timelineProxy.sendVoiceMessageUrlAudioInfoWaveformRequestHandleReturnValue = .failure(.sdkError(SDKError.generic)) guard case .failure(.failedSendingVoiceMessage) = await voiceMessageRecorder.sendVoiceMessage(timelineController: timelineController, audioConverter: audioConverter) else { - XCTFail("An error is expected") + Issue.record("An error is expected") return } } - func testSendVoiceMessage_WaveformAnlyseFailed() async { - guard let imageFileURL = Bundle(for: Self.self).url(forResource: "test_image", withExtension: "png") else { - XCTFail("Test audio file is missing") - 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) @@ -254,16 +263,14 @@ class VoiceMessageRecorderTests: XCTestCase { timelineProxy.sendVoiceMessageUrlAudioInfoWaveformRequestHandleReturnValue = .failure(.sdkError(SDKError.generic)) guard case .failure(.failedSendingVoiceMessage) = await voiceMessageRecorder.sendVoiceMessage(timelineController: timelineController, audioConverter: audioConverter) else { - XCTFail("An error is expected") + Issue.record("An error is expected") return } } - func testSendVoiceMessage_SendError() async { - guard let audioFileURL = Bundle(for: Self.self).url(forResource: "test_voice_message", withExtension: "m4a") else { - XCTFail("Test audio file is missing") - 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) @@ -277,16 +284,14 @@ class VoiceMessageRecorderTests: XCTestCase { timelineProxy.sendVoiceMessageUrlAudioInfoWaveformRequestHandleReturnValue = .failure(.sdkError(SDKError.generic)) guard case .failure(.failedSendingVoiceMessage) = await voiceMessageRecorder.sendVoiceMessage(timelineController: timelineController, audioConverter: audioConverter) else { - XCTFail("An error is expected") + Issue.record("An error is expected") return } } - func testSendVoiceMessage() async { - guard let imageFileURL = Bundle(for: Self.self).url(forResource: "test_voice_message", withExtension: "m4a") else { - XCTFail("Test audio file is missing") - 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) @@ -305,38 +310,39 @@ class VoiceMessageRecorderTests: XCTestCase { try internalConverter.convertToOpusOgg(sourceURL: source, destinationURL: destination) convertedFileSize = try? UInt64(FileManager.default.sizeForItem(at: destination)) // the source URL must be the recorded file - XCTAssertEqual(source, imageFileURL) + #expect(source == imageFileURL) // check the converted file extension - XCTAssertEqual(destination.pathExtension, "ogg") + #expect(destination.pathExtension == "ogg") } timelineProxy.sendVoiceMessageUrlAudioInfoWaveformRequestHandleClosure = { url, audioInfo, waveform, _ in - XCTAssertEqual(url, convertedFileURL) - XCTAssertEqual(audioInfo.duration, self.audioRecorder.currentTime) - XCTAssertEqual(audioInfo.size, convertedFileSize) - XCTAssertEqual(audioInfo.mimetype, "audio/ogg") - XCTAssertFalse(waveform.isEmpty) + #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 { - XCTFail("A success is expected") + Issue.record("A success is expected") return } - XCTAssert(audioConverter.convertToOpusOggSourceURLDestinationURLCalled) - XCTAssert(timelineProxy.sendVoiceMessageUrlAudioInfoWaveformRequestHandleCalled) + #expect(audioConverter.convertToOpusOggSourceURLDestinationURLCalled) + #expect(timelineProxy.sendVoiceMessageUrlAudioInfoWaveformRequestHandleCalled) // the converted file must have been deleted if let convertedFileURL { - XCTAssertFalse(FileManager.default.fileExists(atPath: convertedFileURL.path())) + #expect(!FileManager.default.fileExists(atPath: convertedFileURL.path())) } else { - XCTFail("converted file URL is missing") + Issue.record("converted file URL is missing") } } - func testAudioRecorderActionHandling_didStartRecording() async throws { + @Test + func audioRecorderActionHandling_didStartRecording() async throws { let deferred = deferFulfillment(voiceMessageRecorder.actions) { action in switch action { case .didStartRecording: @@ -349,13 +355,14 @@ class VoiceMessageRecorderTests: XCTestCase { try await deferred.fulfill() } - func testAudioRecorderActionHandling_didStopRecording() async throws { + @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 == self.recordingURL: + case .didStopRecording(_, let url) where url == recordingURL: return true default: return false @@ -365,7 +372,8 @@ class VoiceMessageRecorderTests: XCTestCase { try await deferred.fulfill() } - func testAudioRecorderActionHandling_didFailed() async throws { + @Test + func audioRecorderActionHandling_didFailed() async throws { audioRecorder.audioFileURL = recordingURL let deferred = deferFulfillment(voiceMessageRecorder.actions) { action in