From 631aedc1e4acdc5728f6e2e2ce0712604d85323d Mon Sep 17 00:00:00 2001 From: Rachel Brindle Date: Sun, 7 May 2023 15:36:23 -0700 Subject: [PATCH 1/8] Create AsyncPredicate, for allowing async functions in predicates. This will not ever replace the standard Predicates, but is meant to be a companion to it. --- Nimble.xcodeproj/project.pbxproj | 30 ++ Sources/Nimble/Expectation.swift | 62 ++++ Sources/Nimble/Expression.swift | 17 +- Sources/Nimble/Matchers/AsyncPredicate.swift | 106 +++++++ .../Matchers/ContainElementSatisfying.swift | 23 ++ Sources/Nimble/Polling+AsyncAwait.swift | 267 +++++++++++++++++- Tests/NimbleTests/Helpers/AsyncHelpers.swift | 31 ++ .../Matchers/AsyncPredicateTest.swift | 64 +++++ .../ContainElementSatisfyingTest.swift | 69 +++++ 9 files changed, 654 insertions(+), 15 deletions(-) create mode 100644 Sources/Nimble/Matchers/AsyncPredicate.swift create mode 100644 Tests/NimbleTests/Helpers/AsyncHelpers.swift create mode 100644 Tests/NimbleTests/Matchers/AsyncPredicateTest.swift diff --git a/Nimble.xcodeproj/project.pbxproj b/Nimble.xcodeproj/project.pbxproj index 49b0f2271..c864fa524 100644 --- a/Nimble.xcodeproj/project.pbxproj +++ b/Nimble.xcodeproj/project.pbxproj @@ -369,6 +369,18 @@ 899441F92902EF2600C1FAF9 /* DSL+AsyncAwait.swift in Sources */ = {isa = PBXBuildFile; fileRef = 899441F32902EF0900C1FAF9 /* DSL+AsyncAwait.swift */; }; 899441FA2902EF2700C1FAF9 /* DSL+AsyncAwait.swift in Sources */ = {isa = PBXBuildFile; fileRef = 899441F32902EF0900C1FAF9 /* DSL+AsyncAwait.swift */; }; 899441FB2902EF2800C1FAF9 /* DSL+AsyncAwait.swift in Sources */ = {isa = PBXBuildFile; fileRef = 899441F32902EF0900C1FAF9 /* DSL+AsyncAwait.swift */; }; + 89EEF5A52A03293100988224 /* AsyncPredicate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89EEF5A42A03293100988224 /* AsyncPredicate.swift */; }; + 89EEF5A62A03293100988224 /* AsyncPredicate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89EEF5A42A03293100988224 /* AsyncPredicate.swift */; }; + 89EEF5A72A03293100988224 /* AsyncPredicate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89EEF5A42A03293100988224 /* AsyncPredicate.swift */; }; + 89EEF5A82A03293100988224 /* AsyncPredicate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89EEF5A42A03293100988224 /* AsyncPredicate.swift */; }; + 89EEF5B72A032C3200988224 /* AsyncPredicateTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89EEF5B22A032C2500988224 /* AsyncPredicateTest.swift */; }; + 89EEF5B82A032C3300988224 /* AsyncPredicateTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89EEF5B22A032C2500988224 /* AsyncPredicateTest.swift */; }; + 89EEF5B92A032C3300988224 /* AsyncPredicateTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89EEF5B22A032C2500988224 /* AsyncPredicateTest.swift */; }; + 89EEF5BA2A032C3400988224 /* AsyncPredicateTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89EEF5B22A032C2500988224 /* AsyncPredicateTest.swift */; }; + 89EEF5C02A06211C00988224 /* AsyncHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89EEF5BB2A06210D00988224 /* AsyncHelpers.swift */; }; + 89EEF5C12A06211D00988224 /* AsyncHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89EEF5BB2A06210D00988224 /* AsyncHelpers.swift */; }; + 89EEF5C22A06211E00988224 /* AsyncHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89EEF5BB2A06210D00988224 /* AsyncHelpers.swift */; }; + 89EEF5C32A06211F00988224 /* AsyncHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89EEF5BB2A06210D00988224 /* AsyncHelpers.swift */; }; 89F5E06D290765BB001F9377 /* PollingTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89F5E06C290765BB001F9377 /* PollingTest.swift */; }; 89F5E06E290765BB001F9377 /* PollingTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89F5E06C290765BB001F9377 /* PollingTest.swift */; }; 89F5E06F290765BB001F9377 /* PollingTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89F5E06C290765BB001F9377 /* PollingTest.swift */; }; @@ -768,6 +780,9 @@ 898F28AF25D9F4C30052B8D0 /* AlwaysFailMatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlwaysFailMatcher.swift; sourceTree = ""; }; 899441EE2902EE4B00C1FAF9 /* AsyncAwaitTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncAwaitTest.swift; sourceTree = ""; }; 899441F32902EF0900C1FAF9 /* DSL+AsyncAwait.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DSL+AsyncAwait.swift"; sourceTree = ""; }; + 89EEF5A42A03293100988224 /* AsyncPredicate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncPredicate.swift; sourceTree = ""; }; + 89EEF5B22A032C2500988224 /* AsyncPredicateTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncPredicateTest.swift; sourceTree = ""; }; + 89EEF5BB2A06210D00988224 /* AsyncHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncHelpers.swift; sourceTree = ""; }; 89F5E06C290765BB001F9377 /* PollingTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PollingTest.swift; sourceTree = ""; }; 89F5E0852908E655001F9377 /* Polling+AsyncAwait.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Polling+AsyncAwait.swift"; sourceTree = ""; }; 89F5E08B290B8D22001F9377 /* AsyncAwait.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncAwait.swift; sourceTree = ""; }; @@ -881,6 +896,7 @@ children = ( 1F0648CB19639F5A001F9C46 /* ObjectWithLazyProperty.swift */, 52F5CD6427EE571C00B19809 /* BackgroundThreadObject.swift */, + 89EEF5BB2A06210D00988224 /* AsyncHelpers.swift */, ); path = Helpers; sourceTree = ""; @@ -994,6 +1010,7 @@ children = ( DD72EC631A93874A002F7651 /* AllPassTest.swift */, 898F28AF25D9F4C30052B8D0 /* AlwaysFailMatcher.swift */, + 89EEF5B22A032C2500988224 /* AsyncPredicateTest.swift */, 1F1B5AD31963E13900CA8BF9 /* BeAKindOfTest.swift */, 1F925EE8195C124400ED456B /* BeAnInstanceOfTest.swift */, 1F925EF5195C147800ED456B /* BeCloseToTest.swift */, @@ -1049,6 +1066,7 @@ isa = PBXGroup; children = ( DDB1BC781A92235600F743C3 /* AllPass.swift */, + 89EEF5A42A03293100988224 /* AsyncPredicate.swift */, 1FD8CD0E1968AB07008ED995 /* BeAKindOf.swift */, 1FD8CD0D1968AB07008ED995 /* BeAnInstanceOf.swift */, 1FD8CD0F1968AB07008ED995 /* BeCloseTo.swift */, @@ -1669,6 +1687,7 @@ 1FD8CD381968AB07008ED995 /* Expression.swift in Sources */, 1FD8CD3A1968AB07008ED995 /* FailureMessage.swift in Sources */, CDFB6A4C1F7E082500AD8CC7 /* mach_excServer.c in Sources */, + 89EEF5A62A03293100988224 /* AsyncPredicate.swift in Sources */, 472FD1351B9E085700C7B8DA /* HaveCount.swift in Sources */, 1FA0C4001E30B14500623165 /* Predicate.swift in Sources */, 964CFEFD1C4FF48900513336 /* ThrowAssertion.swift in Sources */, @@ -1681,6 +1700,7 @@ files = ( 1F4A569A1A3B3539009E1637 /* ObjCEqualTest.m in Sources */, 898F28B125D9F4C30052B8D0 /* AlwaysFailMatcher.swift in Sources */, + 89EEF5B82A032C3300988224 /* AsyncPredicateTest.swift in Sources */, 1F925EEC195C12C800ED456B /* RaisesExceptionTest.swift in Sources */, 899441F02902EE4B00C1FAF9 /* AsyncAwaitTest.swift in Sources */, 89F5E098290C37B8001F9377 /* StatusTest.swift in Sources */, @@ -1714,6 +1734,7 @@ CDC157922511957100EAA480 /* DSLTest.swift in Sources */, 7A6AB2C31E7F547E00A2F694 /* ToSucceedTest.swift in Sources */, A8A3B707207368F000E25A08 /* ObjCSatisfyAllOfTest.m in Sources */, + 89EEF5C12A06211D00988224 /* AsyncHelpers.swift in Sources */, 1F4A56701A3B319F009E1637 /* ObjCBeCloseToTest.m in Sources */, 1F4A56971A3B34AA009E1637 /* ObjCEndWithTest.m in Sources */, 1F4A567C1A3B3311009E1637 /* ObjCBeIdenticalToTest.m in Sources */, @@ -1822,6 +1843,7 @@ CDD80B851F20307B0002CD65 /* MatcherProtocols.swift in Sources */, 1F5DF1721BDCA0F500C3A531 /* Expectation.swift in Sources */, 7B5358C01C38479700A23FAA /* SatisfyAnyOf.swift in Sources */, + 89EEF5A72A03293100988224 /* AsyncPredicate.swift in Sources */, 0477153723B740B800402D4E /* NimbleTimeInterval.swift in Sources */, 7B13BA0C1DD361D300C9098C /* ContainElementSatisfying.swift in Sources */, 1F5DF1871BDCA0F500C3A531 /* Match.swift in Sources */, @@ -1834,6 +1856,7 @@ files = ( CD79C9AD1D2CC848004B6F9A /* ObjCBeTrueTest.m in Sources */, 898F28B225D9F4C30052B8D0 /* AlwaysFailMatcher.swift in Sources */, + 89EEF5B92A032C3300988224 /* AsyncPredicateTest.swift in Sources */, CD79C9B41D2CC848004B6F9A /* ObjCRaiseExceptionTest.m in Sources */, 899441F12902EE4B00C1FAF9 /* AsyncAwaitTest.swift in Sources */, 62FB326923B78D4F0047BED9 /* BeginWithPrefixTest.swift in Sources */, @@ -1867,6 +1890,7 @@ 1F5DF1971BDCA10200C3A531 /* AllPassTest.swift in Sources */, CD79C9A61D2CC848004B6F9A /* ObjCBeGreaterThanOrEqualToTest.m in Sources */, CD79C99F1D2CC835004B6F9A /* ObjCSyncTest.m in Sources */, + 89EEF5C22A06211E00988224 /* AsyncHelpers.swift in Sources */, 1FCF91511C61C85A00B15DCB /* PostNotificationTest.swift in Sources */, CD79C9B51D2CC848004B6F9A /* ObjCUserDescriptionTest.m in Sources */, 1F5DF19C1BDCA10200C3A531 /* BeginWithTest.swift in Sources */, @@ -1980,6 +2004,7 @@ CDFB6A251F7E07C700AD8CC7 /* CwlCatchException.m in Sources */, 1FD8CD391968AB07008ED995 /* Expression.swift in Sources */, CDFB6A4B1F7E082500AD8CC7 /* mach_excServer.c in Sources */, + 89EEF5A52A03293100988224 /* AsyncPredicate.swift in Sources */, 1FD8CD3B1968AB07008ED995 /* FailureMessage.swift in Sources */, 1FA0C3FF1E30B14500623165 /* Predicate.swift in Sources */, 472FD1391B9E0A9700C7B8DA /* HaveCount.swift in Sources */, @@ -1992,6 +2017,7 @@ files = ( 1F4A569B1A3B3539009E1637 /* ObjCEqualTest.m in Sources */, 89F5E09F290C37DD001F9377 /* ObjCSyncTest.m in Sources */, + 89EEF5B72A032C3200988224 /* AsyncPredicateTest.swift in Sources */, 898F28B025D9F4C30052B8D0 /* AlwaysFailMatcher.swift in Sources */, 1F925EED195C12C800ED456B /* RaisesExceptionTest.swift in Sources */, 899441EF2902EE4B00C1FAF9 /* AsyncAwaitTest.swift in Sources */, @@ -2025,6 +2051,7 @@ 1F4A56771A3B3253009E1637 /* ObjCBeGreaterThanTest.m in Sources */, CDC157912511957100EAA480 /* DSLTest.swift in Sources */, 1F925EFA195C175000ED456B /* BeNilTest.swift in Sources */, + 89EEF5C02A06211C00988224 /* AsyncHelpers.swift in Sources */, 7A6AB2C21E7F547E00A2F694 /* ToSucceedTest.swift in Sources */, A8A3B706207368EF00E25A08 /* ObjCSatisfyAllOfTest.m in Sources */, 1F4A56711A3B319F009E1637 /* ObjCBeCloseToTest.m in Sources */, @@ -2133,6 +2160,7 @@ D95F8975267EA20A004B1B4D /* BeIdenticalTo.swift in Sources */, D95F8984267EA20E004B1B4D /* SourceLocation.swift in Sources */, D95F8929267EA1CA004B1B4D /* DSL.m in Sources */, + 89EEF5A82A03293100988224 /* AsyncPredicate.swift in Sources */, D95F896B267EA20A004B1B4D /* BeEmpty.swift in Sources */, D95F896E267EA20A004B1B4D /* BeVoid.swift in Sources */, D95F8957267EA1F7004B1B4D /* AdapterProtocols.swift in Sources */, @@ -2145,6 +2173,7 @@ files = ( 8913649B29E695F300AD535E /* ObjCBeIdenticalToTest.m in Sources */, 8913626D29E5C2F500AD535E /* BackgroundThreadObject.swift in Sources */, + 89EEF5BA2A032C3400988224 /* AsyncPredicateTest.swift in Sources */, 8913649829E695F300AD535E /* ObjCEndWithTest.m in Sources */, D95F8945267EA1E8004B1B4D /* BeNilTest.swift in Sources */, D95F8949267EA1E8004B1B4D /* MatchTest.swift in Sources */, @@ -2178,6 +2207,7 @@ 8913649C29E695F300AD535E /* ObjCBeLessThanTest.m in Sources */, 8913649129E6925C00AD535E /* utils.swift in Sources */, D95F892E267EA1D9004B1B4D /* DSLTest.swift in Sources */, + 89EEF5C32A06211F00988224 /* AsyncHelpers.swift in Sources */, D95F8932267EA1E8004B1B4D /* BeGreaterThanOrEqualToTest.swift in Sources */, 891364A029E695F300AD535E /* ObjCHaveCountTest.m in Sources */, 891364A529E695F300AD535E /* ObjCBeginWithTest.m in Sources */, diff --git a/Sources/Nimble/Expectation.swift b/Sources/Nimble/Expectation.swift index 3956ccfd5..8312ada2a 100644 --- a/Sources/Nimble/Expectation.swift +++ b/Sources/Nimble/Expectation.swift @@ -33,6 +33,23 @@ internal func execute(_ expression: Expression, _ style: ExpectationStyle, return result } +internal func execute(_ expression: AsyncExpression, _ style: ExpectationStyle, _ predicate: AsyncPredicate, to: String, description: String?) async -> (Bool, FailureMessage) { + let msg = FailureMessage() + msg.userDescription = description + msg.to = to + do { + let result = try await predicate.satisfies(expression) + result.message.update(failureMessage: msg) + if msg.actualValue == "" { + msg.actualValue = "<\(stringify(try await expression.evaluate()))>" + } + return (result.toBoolean(expectation: style), msg) + } catch let error { + msg.stringValue = "unexpected error thrown: <\(error)>" + return (false, msg) + } +} + public enum ExpectationStatus: Equatable { /// No predicates have been performed. @@ -192,6 +209,29 @@ public struct SyncExpectation: Expectation { toNot(predicate, description: description) } + // MARK: - AsyncPredicates + /// Tests the actual value using a matcher to match. + @discardableResult + public func to(_ predicate: AsyncPredicate, description: String? = nil) async -> Self { + let (pass, msg) = await execute(expression.toAsyncExpression(), .toMatch, predicate, to: "to", description: description) + return verify(pass, msg) + } + + /// Tests the actual value using a matcher to not match. + @discardableResult + public func toNot(_ predicate: AsyncPredicate, description: String? = nil) async -> Self { + let (pass, msg) = await execute(expression.toAsyncExpression(), .toNotMatch, predicate, to: "to not", description: description) + return verify(pass, msg) + } + + /// Tests the actual value using a matcher to not match. + /// + /// Alias to toNot(). + @discardableResult + public func notTo(_ predicate: AsyncPredicate, description: String? = nil) async -> Self { + await toNot(predicate, description: description) + } + // see: // - `Polling.swift` for toEventually and older-style polling-based approach to "async" // - NMBExpectation for Objective-C interface @@ -261,4 +301,26 @@ public struct AsyncExpectation: Expectation { public func notTo(_ predicate: Predicate, description: String? = nil) async -> Self { await toNot(predicate, description: description) } + + /// Tests the actual value using a matcher to match. + @discardableResult + public func to(_ predicate: AsyncPredicate, description: String? = nil) async -> Self { + let (pass, msg) = await execute(expression, .toMatch, predicate, to: "to", description: description) + return verify(pass, msg) + } + + /// Tests the actual value using a matcher to not match. + @discardableResult + public func toNot(_ predicate: AsyncPredicate, description: String? = nil) async -> Self { + let (pass, msg) = await execute(expression, .toNotMatch, predicate, to: "to not", description: description) + return verify(pass, msg) + } + + /// Tests the actual value using a matcher to not match. + /// + /// Alias to toNot(). + @discardableResult + public func notTo(_ predicate: AsyncPredicate, description: String? = nil) async -> Self { + await toNot(predicate, description: description) + } } diff --git a/Sources/Nimble/Expression.swift b/Sources/Nimble/Expression.swift index bae39e451..1bab44fc6 100644 --- a/Sources/Nimble/Expression.swift +++ b/Sources/Nimble/Expression.swift @@ -75,7 +75,7 @@ public struct Expression { /// - Parameter block: The block that can cast the current Expression value to a /// new type. public func cast(_ block: @escaping (Value?) throws -> U?) -> Expression { - return Expression( + Expression( expression: ({ try block(self.evaluate()) }), location: self.location, isClosure: self.isClosure @@ -83,11 +83,11 @@ public struct Expression { } public func evaluate() throws -> Value? { - return try self._expression(_withoutCaching) + try self._expression(_withoutCaching) } public func withoutCaching() -> Expression { - return Expression( + Expression( memoizedExpression: self._expression, location: location, withoutCaching: true, @@ -96,11 +96,20 @@ public struct Expression { } public func withCaching() -> Expression { - return Expression( + Expression( memoizedExpression: memoizedClosure { try self.evaluate() }, location: self.location, withoutCaching: false, isClosure: isClosure ) } + + public func toAsyncExpression() -> AsyncExpression { + AsyncExpression( + memoizedExpression: { @MainActor memoize in try _expression(memoize) }, + location: location, + withoutCaching: _withoutCaching, + isClosure: isClosure + ) + } } diff --git a/Sources/Nimble/Matchers/AsyncPredicate.swift b/Sources/Nimble/Matchers/AsyncPredicate.swift new file mode 100644 index 000000000..2d5bf7807 --- /dev/null +++ b/Sources/Nimble/Matchers/AsyncPredicate.swift @@ -0,0 +1,106 @@ +/// An AsyncPredicate is part of the new matcher API that provides assertions to expectations. +/// +/// Given a code snippet: +/// +/// expect(1).to(equal(2)) +/// ^^^^^^^^ +/// Called a "matcher" +/// +/// A matcher consists of two parts a constructor function and the Predicate. The term Predicate +/// is used as a separate name from Matcher to help transition custom matchers to the new Nimble +/// matcher API. +/// +/// The Predicate provide the heavy lifting on how to assert against a given value. Internally, +/// predicates are simple wrappers around closures to provide static type information and +/// allow composition and wrapping of existing behaviors. +/// +/// `AsyncPredicate`s serve to allow writing matchers that must be run in async contexts. +/// These can also be used with either `Expectation`s or `AsyncExpectation`s. +/// But these can only be used from async contexts, and are unavailable in Objective-C. +/// You can, however, call regular Predicates from an AsyncPredicate, if you wish to compose one like that. +public struct AsyncPredicate { + fileprivate var matcher: (AsyncExpression) async throws -> PredicateResult + + public init(_ matcher: @escaping (AsyncExpression) async throws -> PredicateResult) { + self.matcher = matcher + } + + /// Uses a predicate on a given value to see if it passes the predicate. + /// + /// @param expression The value to run the predicate's logic against + /// @returns A predicate result indicate passing or failing and an associated error message. + public func satisfies(_ expression: AsyncExpression) async throws -> PredicateResult { + return try await matcher(expression) + } +} + +/// Provides convenience helpers to defining predicates +extension AsyncPredicate { + /// Like Predicate() constructor, but automatically guard against nil (actual) values + public static func define(matcher: @escaping (AsyncExpression) async throws -> PredicateResult) -> AsyncPredicate { + return AsyncPredicate { actual in + return try await matcher(actual) + }.requireNonNil + } + + /// Defines a predicate with a default message that can be returned in the closure + /// Also ensures the predicate's actual value cannot pass with `nil` given. + public static func define(_ message: String = "match", matcher: @escaping (AsyncExpression, ExpectationMessage) async throws -> PredicateResult) -> AsyncPredicate { + return AsyncPredicate { actual in + return try await matcher(actual, .expectedActualValueTo(message)) + }.requireNonNil + } + + /// Defines a predicate with a default message that can be returned in the closure + /// Unlike `define`, this allows nil values to succeed if the given closure chooses to. + public static func defineNilable(_ message: String = "match", matcher: @escaping (AsyncExpression, ExpectationMessage) async throws -> PredicateResult) -> AsyncPredicate { + return AsyncPredicate { actual in + return try await matcher(actual, .expectedActualValueTo(message)) + } + } + + /// Provides a simple predicate definition that provides no control over the predefined + /// error message. + /// + /// Also ensures the predicate's actual value cannot pass with `nil` given. + public static func simple(_ message: String = "match", matcher: @escaping (AsyncExpression) async throws -> PredicateStatus) -> AsyncPredicate { + return AsyncPredicate { actual in + return PredicateResult(status: try await matcher(actual), message: .expectedActualValueTo(message)) + }.requireNonNil + } + + /// Provides a simple predicate definition that provides no control over the predefined + /// error message. + /// + /// Unlike `simple`, this allows nil values to succeed if the given closure chooses to. + public static func simpleNilable(_ message: String = "match", matcher: @escaping (AsyncExpression) async throws -> PredicateStatus) -> AsyncPredicate { + return AsyncPredicate { actual in + return PredicateResult(status: try await matcher(actual), message: .expectedActualValueTo(message)) + } + } +} + +extension AsyncPredicate { + // Someday, make this public? Needs documentation + internal func after(f: @escaping (AsyncExpression, PredicateResult) async throws -> PredicateResult) -> AsyncPredicate { + // swiftlint:disable:previous identifier_name + return AsyncPredicate { actual -> PredicateResult in + let result = try await self.satisfies(actual) + return try await f(actual, result) + } + } + + /// Returns a new Predicate based on the current one that always fails if nil is given as + /// the actual value. + public var requireNonNil: AsyncPredicate { + return after { actual, result in + if try await actual.evaluate() == nil { + return PredicateResult( + status: .fail, + message: result.message.appendedBeNilHint() + ) + } + return result + } + } +} diff --git a/Sources/Nimble/Matchers/ContainElementSatisfying.swift b/Sources/Nimble/Matchers/ContainElementSatisfying.swift index 308bdca4f..763e781a5 100644 --- a/Sources/Nimble/Matchers/ContainElementSatisfying.swift +++ b/Sources/Nimble/Matchers/ContainElementSatisfying.swift @@ -21,6 +21,29 @@ public func containElementSatisfying( } } +public func containElementSatisfying( + _ predicate: @escaping ((S.Element) async -> Bool), _ predicateDescription: String = "" +) -> AsyncPredicate { + return AsyncPredicate.define { actualExpression in + let message: ExpectationMessage + if predicateDescription == "" { + message = .expectedTo("find object in collection that satisfies predicate") + } else { + message = .expectedTo("find object in collection \(predicateDescription)") + } + + if let sequence = try await actualExpression.evaluate() { + for object in sequence where await predicate(object) { + return PredicateResult(bool: true, message: message) + } + + return PredicateResult(bool: false, message: message) + } + + return PredicateResult(status: .fail, message: message) + } +} + #if canImport(Darwin) import class Foundation.NSObject import struct Foundation.NSFastEnumerationIterator diff --git a/Sources/Nimble/Polling+AsyncAwait.swift b/Sources/Nimble/Polling+AsyncAwait.swift index dd710ddfd..04b11ed12 100644 --- a/Sources/Nimble/Polling+AsyncAwait.swift +++ b/Sources/Nimble/Polling+AsyncAwait.swift @@ -73,18 +73,8 @@ private func poll( ) } -private extension Expression { - func toAsyncExpression() -> AsyncExpression { - AsyncExpression( - memoizedExpression: { memoize in try _expression(memoize) }, - location: location, - withoutCaching: _withoutCaching, - isClosure: isClosure - ) - } -} - extension SyncExpectation { + // MARK: - With Synchronous Predicates /// Tests the actual value using a matcher to match by checking continuously /// at each pollInterval until the timeout is reached. @discardableResult @@ -213,9 +203,140 @@ extension SyncExpectation { public func alwaysTo(_ predicate: Predicate, until: NimbleTimeInterval = PollingDefaults.timeout, pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, description: String? = nil) async -> Self { return await toAlways(predicate, until: until, pollInterval: pollInterval, description: description) } + + // MARK: - With AsyncPredicates + /// Tests the actual value using a matcher to match by checking continuously + /// at each pollInterval until the timeout is reached. + @discardableResult + public func toEventually(_ predicate: AsyncPredicate, timeout: NimbleTimeInterval = PollingDefaults.timeout, pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, description: String? = nil) async -> Self { + nimblePrecondition(expression.isClosure, "NimbleInternalError", toEventuallyRequiresClosureError.stringValue) + + let asyncExpression = expression.toAsyncExpression() + + let (pass, msg) = await execute( + asyncExpression, + style: .toMatch, + to: "to eventually", + description: description) { + await poll( + expression: asyncExpression, + style: .toMatch, + matchStyle: .eventually, + timeout: timeout, + poll: pollInterval, + fnName: "toEventually") { @MainActor in + try await predicate.satisfies(expression.withoutCaching().toAsyncExpression()) + } + } + return verify(pass, msg) + } + + /// Tests the actual value using a matcher to not match by checking + /// continuously at each pollInterval until the timeout is reached. + @discardableResult + public func toEventuallyNot(_ predicate: AsyncPredicate, timeout: NimbleTimeInterval = PollingDefaults.timeout, pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, description: String? = nil) async -> Self { + nimblePrecondition(expression.isClosure, "NimbleInternalError", toEventuallyRequiresClosureError.stringValue) + + let asyncExpression = expression.toAsyncExpression() + + let (pass, msg) = await execute( + asyncExpression, + style: .toNotMatch, + to: "to eventually not", + description: description) { + await poll( + expression: asyncExpression, + style: .toNotMatch, + matchStyle: .eventually, + timeout: timeout, + poll: pollInterval, + fnName: "toEventuallyNot") { @MainActor in + try await predicate.satisfies(expression.withoutCaching().toAsyncExpression()) + } + } + return verify(pass, msg) + } + + /// Tests the actual value using a matcher to not match by checking + /// continuously at each pollInterval until the timeout is reached. + /// + /// Alias of toEventuallyNot() + @discardableResult + public func toNotEventually(_ predicate: AsyncPredicate, timeout: NimbleTimeInterval = PollingDefaults.timeout, pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, description: String? = nil) async -> Self { + return await toEventuallyNot(predicate, timeout: timeout, pollInterval: pollInterval, description: description) + } + + /// Tests the actual value using a matcher to never match by checking + /// continuously at each pollInterval until the timeout is reached. + @discardableResult + public func toNever(_ predicate: AsyncPredicate, until: NimbleTimeInterval = PollingDefaults.timeout, pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, description: String? = nil) async -> Self { + nimblePrecondition(expression.isClosure, "NimbleInternalError", toEventuallyRequiresClosureError.stringValue) + let asyncExpression = expression.toAsyncExpression() + + let (pass, msg) = await execute( + asyncExpression, + style: .toNotMatch, + to: "to never", + description: description) { + await poll( + expression: asyncExpression, + style: .toMatch, + matchStyle: .never, + timeout: until, + poll: pollInterval, + fnName: "toNever") { @MainActor in + try await predicate.satisfies(expression.withoutCaching().toAsyncExpression()) + } + } + return verify(pass, msg) + } + + /// Tests the actual value using a matcher to never match by checking + /// continuously at each pollInterval until the timeout is reached. + /// + /// Alias of toNever() + @discardableResult + public func neverTo(_ predicate: AsyncPredicate, until: NimbleTimeInterval = PollingDefaults.timeout, pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, description: String? = nil) async -> Self { + return await toNever(predicate, until: until, pollInterval: pollInterval, description: description) + } + + /// Tests the actual value using a matcher to always match by checking + /// continusouly at each pollInterval until the timeout is reached + @discardableResult + public func toAlways(_ predicate: AsyncPredicate, until: NimbleTimeInterval = PollingDefaults.timeout, pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, description: String? = nil) async -> Self { + nimblePrecondition(expression.isClosure, "NimbleInternalError", toEventuallyRequiresClosureError.stringValue) + let asyncExpression = expression.toAsyncExpression() + + let (pass, msg) = await execute( + asyncExpression, + style: .toMatch, + to: "to always", + description: description) { + await poll( + expression: asyncExpression, + style: .toNotMatch, + matchStyle: .always, + timeout: until, + poll: pollInterval, + fnName: "toAlways") { @MainActor in + try await predicate.satisfies(expression.withoutCaching().toAsyncExpression()) + } + } + return verify(pass, msg) + } + + /// Tests the actual value using a matcher to always match by checking + /// continusouly at each pollInterval until the timeout is reached + /// + /// Alias of toAlways() + @discardableResult + public func alwaysTo(_ predicate: AsyncPredicate, until: NimbleTimeInterval = PollingDefaults.timeout, pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, description: String? = nil) async -> Self { + return await toAlways(predicate, until: until, pollInterval: pollInterval, description: description) + } } extension AsyncExpectation { + // MARK: - With Synchronous Predicates /// Tests the actual value using a matcher to match by checking continuously /// at each pollInterval until the timeout is reached. @discardableResult @@ -338,6 +459,130 @@ extension AsyncExpectation { public func alwaysTo(_ predicate: Predicate, until: NimbleTimeInterval = PollingDefaults.timeout, pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, description: String? = nil) async -> Self { return await toAlways(predicate, until: until, pollInterval: pollInterval, description: description) } + + // MARK: - With AsyncPredicates + /// Tests the actual value using a matcher to match by checking continuously + /// at each pollInterval until the timeout is reached. + @discardableResult + public func toEventually(_ predicate: AsyncPredicate, timeout: NimbleTimeInterval = PollingDefaults.timeout, pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, description: String? = nil) async -> Self { + nimblePrecondition(expression.isClosure, "NimbleInternalError", toEventuallyRequiresClosureError.stringValue) + + let (pass, msg) = await execute( + expression, + style: .toMatch, + to: "to eventually", + description: description) { + await poll( + expression: expression, + style: .toMatch, + matchStyle: .eventually, + timeout: timeout, + poll: pollInterval, + fnName: "toEventually") { + try await predicate.satisfies(expression.withoutCaching()) + } + } + return verify(pass, msg) + } + + /// Tests the actual value using a matcher to not match by checking + /// continuously at each pollInterval until the timeout is reached. + @discardableResult + public func toEventuallyNot(_ predicate: AsyncPredicate, timeout: NimbleTimeInterval = PollingDefaults.timeout, pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, description: String? = nil) async -> Self { + nimblePrecondition(expression.isClosure, "NimbleInternalError", toEventuallyRequiresClosureError.stringValue) + + let (pass, msg) = await execute( + expression, + style: .toNotMatch, + to: "to eventually not", + description: description) { + await poll( + expression: expression, + style: .toNotMatch, + matchStyle: .eventually, + timeout: timeout, + poll: pollInterval, + fnName: "toEventuallyNot") { + try await predicate.satisfies(expression.withoutCaching()) + } + } + return verify(pass, msg) + } + + /// Tests the actual value using a matcher to not match by checking + /// continuously at each pollInterval until the timeout is reached. + /// + /// Alias of toEventuallyNot() + @discardableResult + public func toNotEventually(_ predicate: AsyncPredicate, timeout: NimbleTimeInterval = PollingDefaults.timeout, pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, description: String? = nil) async -> Self { + return await toEventuallyNot(predicate, timeout: timeout, pollInterval: pollInterval, description: description) + } + + /// Tests the actual value using a matcher to never match by checking + /// continuously at each pollInterval until the timeout is reached. + @discardableResult + public func toNever(_ predicate: AsyncPredicate, until: NimbleTimeInterval = PollingDefaults.timeout, pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, description: String? = nil) async -> Self { + nimblePrecondition(expression.isClosure, "NimbleInternalError", toEventuallyRequiresClosureError.stringValue) + + let (pass, msg) = await execute( + expression, + style: .toNotMatch, + to: "to never", + description: description) { + await poll( + expression: expression, + style: .toMatch, + matchStyle: .never, + timeout: until, + poll: pollInterval, + fnName: "toNever") { + try await predicate.satisfies(expression.withoutCaching()) + } + } + return verify(pass, msg) + } + + /// Tests the actual value using a matcher to never match by checking + /// continuously at each pollInterval until the timeout is reached. + /// + /// Alias of toNever() + @discardableResult + public func neverTo(_ predicate: AsyncPredicate, until: NimbleTimeInterval = PollingDefaults.timeout, pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, description: String? = nil) async -> Self { + return await toNever(predicate, until: until, pollInterval: pollInterval, description: description) + } + + /// Tests the actual value using a matcher to always match by checking + /// continusouly at each pollInterval until the timeout is reached + @discardableResult + public func toAlways(_ predicate: AsyncPredicate, until: NimbleTimeInterval = PollingDefaults.timeout, pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, description: String? = nil) async -> Self { + nimblePrecondition(expression.isClosure, "NimbleInternalError", toEventuallyRequiresClosureError.stringValue) + + let (pass, msg) = await execute( + expression, + style: .toMatch, + to: "to always", + description: description) { + await poll( + expression: expression, + style: .toNotMatch, + matchStyle: .always, + timeout: until, + poll: pollInterval, + fnName: "toAlways") { + try await predicate.satisfies(expression.withoutCaching()) + } + } + return verify(pass, msg) + } + + /// Tests the actual value using a matcher to always match by checking + /// continusouly at each pollInterval until the timeout is reached + /// + /// Alias of toAlways() + @discardableResult + public func alwaysTo(_ predicate: AsyncPredicate, until: NimbleTimeInterval = PollingDefaults.timeout, pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, description: String? = nil) async -> Self { + return await toAlways(predicate, until: until, pollInterval: pollInterval, description: description) + } } #endif // #if !os(WASI) diff --git a/Tests/NimbleTests/Helpers/AsyncHelpers.swift b/Tests/NimbleTests/Helpers/AsyncHelpers.swift new file mode 100644 index 000000000..3edd269e2 --- /dev/null +++ b/Tests/NimbleTests/Helpers/AsyncHelpers.swift @@ -0,0 +1,31 @@ +import Nimble + +func asyncEqual(_ expectedValue: T) -> AsyncPredicate { + AsyncPredicate.define { expression in + let message = ExpectationMessage.expectedActualValueTo("equal \(expectedValue)") + if let value = try await expression.evaluate() { + return PredicateResult(bool: value == expectedValue, message: message) + } else { + return PredicateResult(status: .fail, message: message.appendedBeNilHint()) + } + } +} + +func asyncContain(_ items: S.Element...) -> AsyncPredicate where S.Element: Equatable { + return asyncContain(items) +} + +func asyncContain(_ items: [S.Element]) -> AsyncPredicate where S.Element: Equatable { + return AsyncPredicate.simple("contain <\(String(describing: items))>") { actualExpression in + guard let actual = try await actualExpression.evaluate() else { return .fail } + + let matches = items.allSatisfy { + return actual.contains($0) + } + return PredicateStatus(bool: matches) + } +} + +func asyncEqualityCheck(_ received: T, _ expected: T) async -> Bool { + received == expected +} diff --git a/Tests/NimbleTests/Matchers/AsyncPredicateTest.swift b/Tests/NimbleTests/Matchers/AsyncPredicateTest.swift new file mode 100644 index 000000000..7ff341864 --- /dev/null +++ b/Tests/NimbleTests/Matchers/AsyncPredicateTest.swift @@ -0,0 +1,64 @@ +import Foundation +import XCTest +import Nimble +#if SWIFT_PACKAGE +import NimbleSharedTestHelpers +#endif + +private func beCalled(times: UInt) -> AsyncPredicate { + AsyncPredicate.define { expression in + let message = ExpectationMessage.expectedActualValueTo("be called \(times) times") + if let value = try await expression.evaluate()?.callCount { + return PredicateResult(bool: value == times, message: message) + } else { + return PredicateResult(status: .fail, message: message.appendedBeNilHint()) + } + } +} + +private actor CallCounter { + var callCount: UInt = 0 + + func call() { + callCount += 1 + } +} + +private func asyncFunction(value: T) async -> T { return value } + +final class AsyncPredicateTest: XCTestCase { + func testAsyncPredicatesWithAsyncExpectations() async { + await expecta(await asyncFunction(value: 1)).to(asyncEqual(1)) + } + + func testAsyncPredicatesWithSyncExpectations() async { + let subject = CallCounter() + await subject.call() + await expects(subject).to(beCalled(times: 1)) + } + +#if !os(WASI) + func testAsyncPollingWithAsyncPredicates() async { + let subject = CallCounter() + + await expect { + await subject.call() + return subject + }.toEventually(beCalled(times: 3)) + + await expect { + await asyncFunction(value: 1) + }.toEventuallyNot(asyncEqual(0)) + + await expect { await asyncFunction(value: 1) }.toNever(asyncEqual(0)) + await expect { await asyncFunction(value: 1) }.toAlways(asyncEqual(1)) + } + + func testSyncPollingWithAsyncPredicates() async { + await expects(1).toEventually(asyncEqual(1)) + await expects(1).toAlways(asyncEqual(1)) + await expects(1).toEventuallyNot(asyncEqual(0)) + await expects(1).toNever(asyncEqual(0)) + } +#endif +} diff --git a/Tests/NimbleTests/Matchers/ContainElementSatisfyingTest.swift b/Tests/NimbleTests/Matchers/ContainElementSatisfyingTest.swift index e05c6f371..35d09249d 100644 --- a/Tests/NimbleTests/Matchers/ContainElementSatisfyingTest.swift +++ b/Tests/NimbleTests/Matchers/ContainElementSatisfyingTest.swift @@ -6,6 +6,7 @@ import NimbleSharedTestHelpers #endif final class ContainElementSatisfyingTest: XCTestCase { + // MARK: - Predicate variant func testContainElementSatisfying() { var orderIndifferentArray = [1, 2, 3] expect(orderIndifferentArray).to(containElementSatisfying({ number in @@ -72,4 +73,72 @@ final class ContainElementSatisfyingTest: XCTestCase { }, "equal to 'kittens'")) } } + + // MARK: - AsyncPredicate variant + func testAsyncContainElementSatisfying() async { + var orderIndifferentArray = [1, 2, 3] + await expect(orderIndifferentArray).to(containElementSatisfying({ number in + await asyncEqualityCheck(number, 1) + })) + await expect(orderIndifferentArray).to(containElementSatisfying({ number in + await asyncEqualityCheck(number, 2) + })) + await expect(orderIndifferentArray).to(containElementSatisfying({ number in + await asyncEqualityCheck(number, 3) + })) + + orderIndifferentArray = [3, 1, 2] + await expect(orderIndifferentArray).to(containElementSatisfying({ number in + await asyncEqualityCheck(number, 1) + })) + await expect(orderIndifferentArray).to(containElementSatisfying({ number in + await asyncEqualityCheck(number, 2) + })) + await expect(orderIndifferentArray).to(containElementSatisfying({ number in + await asyncEqualityCheck(number, 3) + })) + } + + func testAsyncContainElementSatisfyingDefaultErrorMessage() async { + let orderIndifferentArray = [1, 2, 3] + await failsWithErrorMessage("expected to find object in collection that satisfies predicate") { + await expect(orderIndifferentArray).to(containElementSatisfying({ number in + await asyncEqualityCheck(number, 4) + })) + } + } + + func testAsyncContainElementSatisfyingSpecificErrorMessage() async { + let orderIndifferentArray = [1, 2, 3] + await failsWithErrorMessage("expected to find object in collection equal to 4") { + await expect(orderIndifferentArray).to(containElementSatisfying({ number in + await asyncEqualityCheck(number, 4) + }, "equal to 4")) + } + } + + func testAsyncContainElementSatisfyingNegativeCase() async { + let orderIndifferentArray = ["puppies", "kittens", "turtles"] + await expect(orderIndifferentArray).toNot(containElementSatisfying({ string in + await asyncEqualityCheck(string, "armadillos") + })) + } + + func testAsyncContainElementSatisfyingNegativeCaseDefaultErrorMessage() async { + let orderIndifferentArray = ["puppies", "kittens", "turtles"] + await failsWithErrorMessage("expected to not find object in collection that satisfies predicate") { + await expect(orderIndifferentArray).toNot(containElementSatisfying({ string in + await asyncEqualityCheck(string, "kittens") + })) + } + } + + func testAsyncContainElementSatisfyingNegativeCaseSpecificErrorMessage() async { + let orderIndifferentArray = ["puppies", "kittens", "turtles"] + await failsWithErrorMessage("expected to not find object in collection equal to 'kittens'") { + await expect(orderIndifferentArray).toNot(containElementSatisfying({ string in + await asyncEqualityCheck(string, "kittens") + }, "equal to 'kittens'")) + } + } } From d375e5f202623aa2ac755ec3229ce321593cb046 Mon Sep 17 00:00:00 2001 From: Rachel Brindle Date: Sun, 7 May 2023 16:33:04 -0700 Subject: [PATCH 2/8] Allow satisfyAnyOf and satisfyAllOf to take in both Predicates and AsyncPredicates --- Sources/Nimble/AsyncExpression.swift | 9 ++ Sources/Nimble/Matchers/AsyncPredicate.swift | 13 ++- Sources/Nimble/Matchers/SatisfyAllOf.swift | 46 +++++++++ Sources/Nimble/Matchers/SatisfyAnyOf.swift | 94 ++++++++++++++----- Tests/NimbleTests/Helpers/AsyncHelpers.swift | 57 +++++++++++ .../Matchers/AlwaysFailMatcher.swift | 18 ++++ Tests/NimbleTests/Matchers/BeEmptyTest.swift | 2 +- .../Matchers/SatisfyAllOfTest.swift | 69 ++++++++++++++ .../Matchers/SatisfyAnyOfTest.swift | 69 ++++++++++++++ 9 files changed, 351 insertions(+), 26 deletions(-) diff --git a/Sources/Nimble/AsyncExpression.swift b/Sources/Nimble/AsyncExpression.swift index 72ef6a004..e643637c8 100644 --- a/Sources/Nimble/AsyncExpression.swift +++ b/Sources/Nimble/AsyncExpression.swift @@ -110,5 +110,14 @@ public struct AsyncExpression { isClosure: isClosure ) } + + public func withCaching() -> AsyncExpression { + return AsyncExpression( + memoizedExpression: memoizedClosure { try await self.evaluate() }, + location: self.location, + withoutCaching: false, + isClosure: isClosure + ) + } } diff --git a/Sources/Nimble/Matchers/AsyncPredicate.swift b/Sources/Nimble/Matchers/AsyncPredicate.swift index 2d5bf7807..a3aed7d8e 100644 --- a/Sources/Nimble/Matchers/AsyncPredicate.swift +++ b/Sources/Nimble/Matchers/AsyncPredicate.swift @@ -1,3 +1,14 @@ +public protocol AsyncablePredicate { + associatedtype T + func satisfies(_ expression: AsyncExpression) async throws -> PredicateResult +} + +extension Predicate: AsyncablePredicate { + public func satisfies(_ expression: AsyncExpression) async throws -> PredicateResult { + try satisfies(await expression.toSynchronousExpression()) + } +} + /// An AsyncPredicate is part of the new matcher API that provides assertions to expectations. /// /// Given a code snippet: @@ -18,7 +29,7 @@ /// These can also be used with either `Expectation`s or `AsyncExpectation`s. /// But these can only be used from async contexts, and are unavailable in Objective-C. /// You can, however, call regular Predicates from an AsyncPredicate, if you wish to compose one like that. -public struct AsyncPredicate { +public struct AsyncPredicate: AsyncablePredicate { fileprivate var matcher: (AsyncExpression) async throws -> PredicateResult public init(_ matcher: @escaping (AsyncExpression) async throws -> PredicateResult) { diff --git a/Sources/Nimble/Matchers/SatisfyAllOf.swift b/Sources/Nimble/Matchers/SatisfyAllOf.swift index 2df6a7091..d8ecdc521 100644 --- a/Sources/Nimble/Matchers/SatisfyAllOf.swift +++ b/Sources/Nimble/Matchers/SatisfyAllOf.swift @@ -41,6 +41,52 @@ public func && (left: Predicate, right: Predicate) -> Predicate { return satisfyAllOf(left, right) } +/// A Nimble matcher that succeeds when the actual value matches with all of the matchers +/// provided in the variable list of matchers. +@available(macOSApplicationExtension 13.0.0, macOS 13.0.0, iOS 16.0.0, tvOS 16.0.0, watchOS 9.0.0, *) +public func satisfyAllOf(_ predicates: any AsyncablePredicate...) -> AsyncPredicate { + return satisfyAllOf(predicates) +} + +/// A Nimble matcher that succeeds when the actual value matches with all of the matchers +/// provided in the array of matchers. +@available(macOSApplicationExtension 13.0.0, macOS 13.0.0, iOS 16.0.0, tvOS 16.0.0, watchOS 9.0.0, *) +public func satisfyAllOf(_ predicates: [any AsyncablePredicate]) -> AsyncPredicate { + return AsyncPredicate.define { actualExpression in + let cachedExpression = actualExpression.withCaching() + var postfixMessages = [String]() + var status: PredicateStatus = .matches + for predicate in predicates { + let result = try await predicate.satisfies(cachedExpression) + if result.status == .fail { + status = .fail + } else if result.status == .doesNotMatch, status != .fail { + status = .doesNotMatch + } + postfixMessages.append("{\(result.message.expectedMessage)}") + } + + var msg: ExpectationMessage + if let actualValue = try await cachedExpression.evaluate() { + msg = .expectedCustomValueTo( + "match all of: " + postfixMessages.joined(separator: ", and "), + actual: "\(actualValue)" + ) + } else { + msg = .expectedActualValueTo( + "match all of: " + postfixMessages.joined(separator: ", and ") + ) + } + + return PredicateResult(status: status, message: msg) + } +} + +@available(macOSApplicationExtension 13.0.0, macOS 13.0.0, iOS 16.0.0, tvOS 16.0.0, watchOS 9.0.0, *) +public func && (left: some AsyncablePredicate, right: some AsyncablePredicate) -> AsyncPredicate { + return satisfyAllOf(left, right) +} + #if canImport(Darwin) import class Foundation.NSObject diff --git a/Sources/Nimble/Matchers/SatisfyAnyOf.swift b/Sources/Nimble/Matchers/SatisfyAnyOf.swift index a8fadf8b6..56f098434 100644 --- a/Sources/Nimble/Matchers/SatisfyAnyOf.swift +++ b/Sources/Nimble/Matchers/SatisfyAnyOf.swift @@ -7,37 +7,83 @@ public func satisfyAnyOf(_ predicates: Predicate...) -> Predicate { /// A Nimble matcher that succeeds when the actual value matches with any of the matchers /// provided in the array of matchers. public func satisfyAnyOf(_ predicates: [Predicate]) -> Predicate { - return Predicate.define { actualExpression in - let cachedExpression = actualExpression.withCaching() - var postfixMessages = [String]() - var status: PredicateStatus = .doesNotMatch - for predicate in predicates { - let result = try predicate.satisfies(cachedExpression) - if result.status == .fail { - status = .fail - } else if result.status == .matches, status != .fail { - status = .matches - } - postfixMessages.append("{\(result.message.expectedMessage)}") + return Predicate.define { actualExpression in + let cachedExpression = actualExpression.withCaching() + var postfixMessages = [String]() + var status: PredicateStatus = .doesNotMatch + for predicate in predicates { + let result = try predicate.satisfies(cachedExpression) + if result.status == .fail { + status = .fail + } else if result.status == .matches, status != .fail { + status = .matches } + postfixMessages.append("{\(result.message.expectedMessage)}") + } - var msg: ExpectationMessage - if let actualValue = try cachedExpression.evaluate() { - msg = .expectedCustomValueTo( - "match one of: " + postfixMessages.joined(separator: ", or "), - actual: "\(actualValue)" - ) - } else { - msg = .expectedActualValueTo( - "match one of: " + postfixMessages.joined(separator: ", or ") - ) + var msg: ExpectationMessage + if let actualValue = try cachedExpression.evaluate() { + msg = .expectedCustomValueTo( + "match one of: " + postfixMessages.joined(separator: ", or "), + actual: "\(actualValue)" + ) + } else { + msg = .expectedActualValueTo( + "match one of: " + postfixMessages.joined(separator: ", or ") + ) + } + + return PredicateResult(status: status, message: msg) + } +} + +public func || (left: Predicate, right: Predicate) -> Predicate { + return satisfyAnyOf(left, right) +} + +/// A Nimble matcher that succeeds when the actual value matches with any of the matchers +/// provided in the variable list of matchers. +@available(macOSApplicationExtension 13.0.0, macOS 13.0.0, iOS 16.0.0, tvOS 16.0.0, watchOS 9.0.0, *) +public func satisfyAnyOf(_ predicates: any AsyncablePredicate...) -> AsyncPredicate { + return satisfyAnyOf(predicates) +} + +/// A Nimble matcher that succeeds when the actual value matches with any of the matchers +/// provided in the array of matchers. +@available(macOSApplicationExtension 13.0.0, macOS 13.0.0, iOS 16.0.0, tvOS 16.0.0, watchOS 9.0.0, *) +public func satisfyAnyOf(_ predicates: [any AsyncablePredicate]) -> AsyncPredicate { + return AsyncPredicate.define { actualExpression in + let cachedExpression = actualExpression.withCaching() + var postfixMessages = [String]() + var status: PredicateStatus = .doesNotMatch + for predicate in predicates { + let result = try await predicate.satisfies(cachedExpression) + if result.status == .fail { + status = .fail + } else if result.status == .matches, status != .fail { + status = .matches } + postfixMessages.append("{\(result.message.expectedMessage)}") + } - return PredicateResult(status: status, message: msg) + var msg: ExpectationMessage + if let actualValue = try await cachedExpression.evaluate() { + msg = .expectedCustomValueTo( + "match one of: " + postfixMessages.joined(separator: ", or "), + actual: "\(actualValue)" + ) + } else { + msg = .expectedActualValueTo( + "match one of: " + postfixMessages.joined(separator: ", or ") + ) } + + return PredicateResult(status: status, message: msg) + } } -public func || (left: Predicate, right: Predicate) -> Predicate { +@available(macOSApplicationExtension 13.0.0, macOS 13.0.0, iOS 16.0.0, tvOS 16.0.0, watchOS 9.0.0, *) +public func || (left: some AsyncablePredicate, right: some AsyncablePredicate) -> AsyncPredicate { return satisfyAnyOf(left, right) } diff --git a/Tests/NimbleTests/Helpers/AsyncHelpers.swift b/Tests/NimbleTests/Helpers/AsyncHelpers.swift index 3edd269e2..05574fd5c 100644 --- a/Tests/NimbleTests/Helpers/AsyncHelpers.swift +++ b/Tests/NimbleTests/Helpers/AsyncHelpers.swift @@ -1,4 +1,8 @@ import Nimble +import XCTest +#if SWIFT_PACKAGE +import NimbleSharedTestHelpers +#endif func asyncEqual(_ expectedValue: T) -> AsyncPredicate { AsyncPredicate.define { expression in @@ -26,6 +30,59 @@ func asyncContain(_ items: [S.Element]) -> AsyncPredicate where } } +func asyncBeCloseTo( + _ expectedValue: Value +) -> AsyncPredicate { + let delta: Value = 1/10000 + let errorMessage = "be close to <\(stringify(expectedValue))> (within \(stringify(delta)))" + return AsyncPredicate.simple(errorMessage) { actualExpression in + guard let actualValue = try await actualExpression.evaluate() else { + return .doesNotMatch + } + + return PredicateStatus(bool: abs(actualValue - expectedValue) < delta) + } +} + func asyncEqualityCheck(_ received: T, _ expected: T) async -> Bool { received == expected } + +final class AsyncMatchersTest: XCTestCase { + func testAsyncEqual() async { + await expect(1).to(asyncEqual(1)) + await expect(2).toNot(asyncEqual(1)) + + await failsWithErrorMessage("expected to equal 1, got 2") { + await expect(2).to(asyncEqual(1)) + } + } + + func testAsyncContain() async { + await expect([1, 2, 3]).to(asyncContain(1)) + + await expect([1, 2, 3]).to(asyncContain(1, 2)) + await expect([1, 2, 3]).to(asyncContain([1, 2])) + + await expect([1, 2, 3]).to(asyncContain(2, 1)) + await expect([1, 2, 3]).to(asyncContain([2, 1])) + + await expect([1, 2, 3]).toNot(asyncContain(4)) + + await expect([1, 2, 3]).toNot(asyncContain(4, 2)) + await expect([1, 2, 3]).toNot(asyncContain([4, 2])) + + await expect([1, 2, 3]).toNot(asyncContain(2, 4)) + await expect([1, 2, 3]).toNot(asyncContain([2, 4])) + } + + func testAsyncBeCloseTo() async { + await expect(1.2).to(asyncBeCloseTo(1.2001)) + await expect(1.2 as CDouble).to(asyncBeCloseTo(1.2001)) + await expect(1.2 as Float).to(asyncBeCloseTo(1.2001)) + + await failsWithErrorMessage("expected to not be close to <1.2001> (within 0.0001), got <1.2>") { + await expect(1.2).toNot(asyncBeCloseTo(1.2001)) + } + } +} diff --git a/Tests/NimbleTests/Matchers/AlwaysFailMatcher.swift b/Tests/NimbleTests/Matchers/AlwaysFailMatcher.swift index 2df636a6b..c95ef9360 100644 --- a/Tests/NimbleTests/Matchers/AlwaysFailMatcher.swift +++ b/Tests/NimbleTests/Matchers/AlwaysFailMatcher.swift @@ -10,6 +10,12 @@ func alwaysFail() -> Predicate { } } +func asyncAlwaysFail() -> AsyncPredicate { + return AsyncPredicate { _ throws -> PredicateResult in + return PredicateResult(status: .fail, message: .fail("This matcher should always fail")) + } +} + final class AlwaysFailTest: XCTestCase { func testAlwaysFail() { failsWithErrorMessage( @@ -22,4 +28,16 @@ final class AlwaysFailTest: XCTestCase { expect(true).to(alwaysFail()) } } + + func testAsyncAlwaysFail() async { + await failsWithErrorMessage( + "This matcher should always fail") { + await expect(true).toNot(asyncAlwaysFail()) + } + + await failsWithErrorMessage( + "This matcher should always fail") { + await expect(true).to(asyncAlwaysFail()) + } + } } diff --git a/Tests/NimbleTests/Matchers/BeEmptyTest.swift b/Tests/NimbleTests/Matchers/BeEmptyTest.swift index 39536eefd..fdf335ba0 100644 --- a/Tests/NimbleTests/Matchers/BeEmptyTest.swift +++ b/Tests/NimbleTests/Matchers/BeEmptyTest.swift @@ -8,7 +8,7 @@ import NimbleSharedTestHelpers final class BeEmptyTest: XCTestCase { func testBeEmptyPositive() { // Array - expect([] as [Int]).to(beEmpty()) + expect([Int]()).to(beEmpty()) expect([1]).toNot(beEmpty()) expect([] as [CInt]).to(beEmpty()) diff --git a/Tests/NimbleTests/Matchers/SatisfyAllOfTest.swift b/Tests/NimbleTests/Matchers/SatisfyAllOfTest.swift index 7e18b0265..3bfb86d3f 100644 --- a/Tests/NimbleTests/Matchers/SatisfyAllOfTest.swift +++ b/Tests/NimbleTests/Matchers/SatisfyAllOfTest.swift @@ -6,6 +6,7 @@ import NimbleSharedTestHelpers #endif final class SatisfyAllOfTest: XCTestCase { + // MARK: - synchronous variant func testSatisfyAllOf() { expect(2).to(satisfyAllOf(equal(2), beLessThan(3))) expect(2 as NSNumber).toNot(satisfyAllOf(equal(3 as NSNumber), equal("turtles" as NSString))) @@ -65,4 +66,72 @@ final class SatisfyAllOfTest: XCTestCase { expect(testFunction()).toEventually(satisfyAllOf(equal(1), equal(1))) } #endif + + // MARK: - AsyncPredicate variant + @available(macOSApplicationExtension 13.0.0, macOS 13.0.0, iOS 16.0.0, tvOS 16.0.0, watchOS 9.0.0, *) + func testAsyncSatisfyAllOf() async { + await expect(2).to(satisfyAllOf(asyncEqual(2), beLessThan(3))) + await expect(2 as NSNumber).toNot(satisfyAllOf(asyncEqual(3 as NSNumber), equal("turtles" as NSString))) + await expect([1, 2, 3]).to(satisfyAllOf(asyncEqual([1, 2, 3]), allPass({$0 < 4}), haveCount(3))) + await expect("turtle").to(satisfyAllOf(asyncContain("e"), beginWith("tur"))) + await expect(82.0).to(satisfyAllOf(beGreaterThan(10.5), beLessThan(100.75), beCloseTo(82.00001), asyncEqual(82.0))) + await expect(false).toNot(satisfyAllOf(beTrue(), beFalse(), asyncEqual(false))) + await expect(true).toNot(satisfyAllOf(beTruthy(), beFalsy(), asyncEqual(true))) + + await failsWithErrorMessage( + "expected to match all of: {equal <3>}, and {equal <4>}, and {equal <5>}, got 2") { + await expect(2).to(satisfyAllOf(asyncEqual(3), asyncEqual(4), asyncEqual(5))) + } + await failsWithErrorMessage( + "expected to match all of: {all be less than 4, but failed first at element <5> in <[5, 6, 7]>}, and {equal <[5, 6, 7]>}, got [5, 6, 7]") { + await expect([5, 6, 7]).to(satisfyAllOf(allPass("be less than 4", {$0 < 4}), asyncEqual([5, 6, 7]))) + } + await failsWithErrorMessage( + "expected to not match all of: {be false}, got false") { + await expect(false).toNot(satisfyAllOf(beFalse())) + } + await failsWithErrorMessage( + "expected to not match all of: {be greater than <10.5>}, and {be less than <100.75>}, and {be close to <50.1> (within 0.0001)}, got 50.10001") { + await expect(50.10001).toNot(satisfyAllOf(beGreaterThan(10.5), beLessThan(100.75), asyncBeCloseTo(50.1))) + } + await failsWithErrorMessage( + "expected to not match all of: {This matcher should always fail}, and {This matcher should always fail}, got true") { + await expect(true).toNot(satisfyAllOf(asyncAlwaysFail(), alwaysFail())) + } + await failsWithErrorMessage( + "expected to match all of: {This matcher should always fail}, and {This matcher should always fail}, got true") { + await expect(true).to(satisfyAllOf(asyncAlwaysFail(), alwaysFail())) + } + } + + @available(macOSApplicationExtension 13.0.0, macOS 13.0.0, iOS 16.0.0, tvOS 16.0.0, watchOS 9.0.0, *) + func testAsyncOperatorAnd() async { + await expect(2).to(asyncEqual(2) && beLessThan(3)) + await expect(2).to(beLessThan(3) && beGreaterThan(1)) + await expect(2 as NSNumber).to(beLessThan(3 as NSNumber) && beGreaterThan(1 as NSNumber)) + await expect("turtle").to(contain("t") && endWith("tle") && asyncEqual("turtle")) + await expect(82.0).to(beGreaterThan(10.5) && beLessThan(100.75)) + await expect(false).to(beFalsy() && beFalse()) + await expect(false).toNot(beTrue() && beFalse()) + await expect(true).toNot(beTruthy() && beFalsy()) + } + + #if !os(WASI) + @available(macOSApplicationExtension 13.0.0, macOS 13.0.0, iOS 16.0.0, tvOS 16.0.0, watchOS 9.0.0, *) + func testAsyncSatisfyAllOfCachesExpressionBeforePassingToPredicates() async { + // This is not a great example of assertion writing - functions being asserted on in Expressions should not have side effects. + // But we should still handle those cases anyway. + actor Counter { + var value: Int = 0 + func increment() -> Int { + value += 1 + return value + } + } + + let counter = Counter() + + await expecta(await counter.increment()).toEventually(satisfyAllOf(asyncEqual(1), asyncEqual(1))) + } + #endif } diff --git a/Tests/NimbleTests/Matchers/SatisfyAnyOfTest.swift b/Tests/NimbleTests/Matchers/SatisfyAnyOfTest.swift index 9c8d58177..214691117 100644 --- a/Tests/NimbleTests/Matchers/SatisfyAnyOfTest.swift +++ b/Tests/NimbleTests/Matchers/SatisfyAnyOfTest.swift @@ -6,6 +6,7 @@ import NimbleSharedTestHelpers #endif final class SatisfyAnyOfTest: XCTestCase { + // MARK: - Synchronous Variant func testSatisfyAnyOf() { expect(2).to(satisfyAnyOf(equal(2), equal(3))) expect(2 as NSNumber).toNot(satisfyAnyOf(equal(3 as NSNumber), equal("turtles" as NSString))) @@ -65,4 +66,72 @@ final class SatisfyAnyOfTest: XCTestCase { expect(testFunction()).toEventually(satisfyAnyOf(equal(0), equal(1))) } #endif + + // MARK: - Async Variant + @available(macOSApplicationExtension 13.0.0, macOS 13.0.0, iOS 16.0.0, tvOS 16.0.0, watchOS 9.0.0, *) + func testAsyncSatisfyAnyOf() async { + await expect(2).to(satisfyAnyOf(asyncEqual(2), asyncEqual(3))) + await expect(2 as NSNumber).toNot(satisfyAnyOf(asyncEqual(3 as NSNumber), asyncEqual("turtles" as NSString))) + await expect([1, 2, 3]).to(satisfyAnyOf(asyncEqual([1, 2, 3]), allPass({$0 < 4}), haveCount(3))) + await expect("turtle").toNot(satisfyAnyOf(asyncContain("a"), endWith("magic"))) + await expect(82.0).toNot(satisfyAnyOf(beLessThan(10.5), beGreaterThan(100.75), asyncBeCloseTo(50.1))) + await expect(false).to(satisfyAnyOf(beTrue(), beFalse(), asyncEqual(true), asyncEqual(false))) + await expect(true).to(satisfyAnyOf(beTruthy(), beFalsy(), asyncEqual(false), asyncEqual(true))) + + await failsWithErrorMessage( + "expected to match one of: {equal <3>}, or {equal <4>}, or {equal <5>}, got 2") { + await expect(2).to(satisfyAnyOf(asyncEqual(3), asyncEqual(4), asyncEqual(5))) + } + await failsWithErrorMessage( + "expected to match one of: {all be less than 4, but failed first at element <5> in <[5, 6, 7]>}, or {equal <[1, 2, 3, 4]>}, got [5, 6, 7]") { + await expect([5, 6, 7]).to(satisfyAnyOf(allPass("be less than 4", {$0 < 4}), asyncEqual([1, 2, 3, 4]))) + } + await failsWithErrorMessage( + "expected to match one of: {be true}, got false") { + await expect(false).to(satisfyAnyOf(beTrue())) + } + await failsWithErrorMessage( + "expected to not match one of: {be less than <10.5>}, or {be greater than <100.75>}, or {be close to <50.1> (within 0.0001)}, got 50.10001") { + await expect(50.10001).toNot(satisfyAnyOf(beLessThan(10.5), beGreaterThan(100.75), asyncBeCloseTo(50.1))) + } + await failsWithErrorMessage( + "expected to match one of: {This matcher should always fail}, or {This matcher should always fail}, got true") { + await expect(true).to(satisfyAnyOf(asyncAlwaysFail(), asyncAlwaysFail())) + } + await failsWithErrorMessage( + "expected to not match one of: {This matcher should always fail}, or {This matcher should always fail}, got true") { + await expect(true).toNot(satisfyAnyOf(asyncAlwaysFail(), asyncAlwaysFail())) + } + } + + @available(macOSApplicationExtension 13.0.0, macOS 13.0.0, iOS 16.0.0, tvOS 16.0.0, watchOS 9.0.0, *) + func testAsyncOperatorOr() async { + await expect(2).to(asyncEqual(2) || asyncEqual(3)) + await expect(2 as NSNumber).toNot(asyncEqual(3 as NSNumber) || asyncEqual("turtles" as NSString)) + await expect("turtle").toNot(asyncContain("a") || endWith("magic")) + await expect(82.0).toNot(beLessThan(10.5) || beGreaterThan(100.75) || asyncBeCloseTo(83.0)) + await expect(false).to(beTrue() || beFalse() || asyncEqual(true) || asyncEqual(false)) + await expect(true).to(beTruthy() || beFalsy() || asyncEqual(true) || asyncEqual(false)) + } + + #if !os(WASI) + @available(macOSApplicationExtension 13.0.0, macOS 13.0.0, iOS 16.0.0, tvOS 16.0.0, watchOS 9.0.0, *) + func testAsyncSatisfyAllOfCachesExpressionBeforePassingToPredicates() async { + // This is not a great example of assertion writing - functions being asserted on in Expressions should not have side effects. + // But we should still handle those cases anyway. + actor Counter { + var value: Int = 0 + func increment() -> Int { + value += 1 + return value + } + } + + let counter = Counter() + + // This demonstrates caching because the first time this is evaluated, the function should return 1, which doesn't pass the `equal(0)`. + // Next time, it'll return 2, which doesn't pass the `equal(1)`. + await expecta(await counter.increment()).toEventually(satisfyAnyOf(asyncEqual(0), asyncEqual(1))) + } + #endif } From 2256ea673519f40589200a60e4ba15c205cf2831 Mon Sep 17 00:00:00 2001 From: Rachel Brindle Date: Sat, 20 May 2023 21:34:59 -0700 Subject: [PATCH 3/8] Change the some AsyncablePredicate in satisfyAnyOf/satisfyAllOf operators to any AsyncablePredicate This is a workaround for a compiler bug in swift 5.7. --- Sources/Nimble/Matchers/SatisfyAllOf.swift | 8 ++++---- Sources/Nimble/Matchers/SatisfyAnyOf.swift | 8 ++++---- Tests/NimbleTests/Matchers/SatisfyAllOfTest.swift | 6 +++--- Tests/NimbleTests/Matchers/SatisfyAnyOfTest.swift | 6 +++--- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/Sources/Nimble/Matchers/SatisfyAllOf.swift b/Sources/Nimble/Matchers/SatisfyAllOf.swift index d8ecdc521..5e883fe88 100644 --- a/Sources/Nimble/Matchers/SatisfyAllOf.swift +++ b/Sources/Nimble/Matchers/SatisfyAllOf.swift @@ -43,14 +43,14 @@ public func && (left: Predicate, right: Predicate) -> Predicate { /// A Nimble matcher that succeeds when the actual value matches with all of the matchers /// provided in the variable list of matchers. -@available(macOSApplicationExtension 13.0.0, macOS 13.0.0, iOS 16.0.0, tvOS 16.0.0, watchOS 9.0.0, *) +@available(macOS 13.0.0, iOS 16.0.0, tvOS 16.0.0, watchOS 9.0.0, *) public func satisfyAllOf(_ predicates: any AsyncablePredicate...) -> AsyncPredicate { return satisfyAllOf(predicates) } /// A Nimble matcher that succeeds when the actual value matches with all of the matchers /// provided in the array of matchers. -@available(macOSApplicationExtension 13.0.0, macOS 13.0.0, iOS 16.0.0, tvOS 16.0.0, watchOS 9.0.0, *) +@available(macOS 13.0.0, iOS 16.0.0, tvOS 16.0.0, watchOS 9.0.0, *) public func satisfyAllOf(_ predicates: [any AsyncablePredicate]) -> AsyncPredicate { return AsyncPredicate.define { actualExpression in let cachedExpression = actualExpression.withCaching() @@ -82,8 +82,8 @@ public func satisfyAllOf(_ predicates: [any AsyncablePredicate]) -> AsyncP } } -@available(macOSApplicationExtension 13.0.0, macOS 13.0.0, iOS 16.0.0, tvOS 16.0.0, watchOS 9.0.0, *) -public func && (left: some AsyncablePredicate, right: some AsyncablePredicate) -> AsyncPredicate { +@available(macOS 13.0.0, iOS 16.0.0, tvOS 16.0.0, watchOS 9.0.0, *) +public func && (left: any AsyncablePredicate, right: any AsyncablePredicate) -> AsyncPredicate { return satisfyAllOf(left, right) } diff --git a/Sources/Nimble/Matchers/SatisfyAnyOf.swift b/Sources/Nimble/Matchers/SatisfyAnyOf.swift index 56f098434..092b5c52c 100644 --- a/Sources/Nimble/Matchers/SatisfyAnyOf.swift +++ b/Sources/Nimble/Matchers/SatisfyAnyOf.swift @@ -43,14 +43,14 @@ public func || (left: Predicate, right: Predicate) -> Predicate { /// A Nimble matcher that succeeds when the actual value matches with any of the matchers /// provided in the variable list of matchers. -@available(macOSApplicationExtension 13.0.0, macOS 13.0.0, iOS 16.0.0, tvOS 16.0.0, watchOS 9.0.0, *) +@available(macOS 13.0.0, iOS 16.0.0, tvOS 16.0.0, watchOS 9.0.0, *) public func satisfyAnyOf(_ predicates: any AsyncablePredicate...) -> AsyncPredicate { return satisfyAnyOf(predicates) } /// A Nimble matcher that succeeds when the actual value matches with any of the matchers /// provided in the array of matchers. -@available(macOSApplicationExtension 13.0.0, macOS 13.0.0, iOS 16.0.0, tvOS 16.0.0, watchOS 9.0.0, *) +@available(macOS 13.0.0, iOS 16.0.0, tvOS 16.0.0, watchOS 9.0.0, *) public func satisfyAnyOf(_ predicates: [any AsyncablePredicate]) -> AsyncPredicate { return AsyncPredicate.define { actualExpression in let cachedExpression = actualExpression.withCaching() @@ -82,8 +82,8 @@ public func satisfyAnyOf(_ predicates: [any AsyncablePredicate]) -> AsyncP } } -@available(macOSApplicationExtension 13.0.0, macOS 13.0.0, iOS 16.0.0, tvOS 16.0.0, watchOS 9.0.0, *) -public func || (left: some AsyncablePredicate, right: some AsyncablePredicate) -> AsyncPredicate { +@available(macOS 13.0.0, iOS 16.0.0, tvOS 16.0.0, watchOS 9.0.0, *) +public func || (left: any AsyncablePredicate, right: any AsyncablePredicate) -> AsyncPredicate { return satisfyAnyOf(left, right) } diff --git a/Tests/NimbleTests/Matchers/SatisfyAllOfTest.swift b/Tests/NimbleTests/Matchers/SatisfyAllOfTest.swift index 3bfb86d3f..3eee1cca4 100644 --- a/Tests/NimbleTests/Matchers/SatisfyAllOfTest.swift +++ b/Tests/NimbleTests/Matchers/SatisfyAllOfTest.swift @@ -68,7 +68,7 @@ final class SatisfyAllOfTest: XCTestCase { #endif // MARK: - AsyncPredicate variant - @available(macOSApplicationExtension 13.0.0, macOS 13.0.0, iOS 16.0.0, tvOS 16.0.0, watchOS 9.0.0, *) + @available(macOS 13.0.0, iOS 16.0.0, tvOS 16.0.0, watchOS 9.0.0, *) func testAsyncSatisfyAllOf() async { await expect(2).to(satisfyAllOf(asyncEqual(2), beLessThan(3))) await expect(2 as NSNumber).toNot(satisfyAllOf(asyncEqual(3 as NSNumber), equal("turtles" as NSString))) @@ -104,7 +104,7 @@ final class SatisfyAllOfTest: XCTestCase { } } - @available(macOSApplicationExtension 13.0.0, macOS 13.0.0, iOS 16.0.0, tvOS 16.0.0, watchOS 9.0.0, *) + @available(macOS 13.0.0, iOS 16.0.0, tvOS 16.0.0, watchOS 9.0.0, *) func testAsyncOperatorAnd() async { await expect(2).to(asyncEqual(2) && beLessThan(3)) await expect(2).to(beLessThan(3) && beGreaterThan(1)) @@ -117,7 +117,7 @@ final class SatisfyAllOfTest: XCTestCase { } #if !os(WASI) - @available(macOSApplicationExtension 13.0.0, macOS 13.0.0, iOS 16.0.0, tvOS 16.0.0, watchOS 9.0.0, *) + @available(macOS 13.0.0, iOS 16.0.0, tvOS 16.0.0, watchOS 9.0.0, *) func testAsyncSatisfyAllOfCachesExpressionBeforePassingToPredicates() async { // This is not a great example of assertion writing - functions being asserted on in Expressions should not have side effects. // But we should still handle those cases anyway. diff --git a/Tests/NimbleTests/Matchers/SatisfyAnyOfTest.swift b/Tests/NimbleTests/Matchers/SatisfyAnyOfTest.swift index 214691117..8da6678a5 100644 --- a/Tests/NimbleTests/Matchers/SatisfyAnyOfTest.swift +++ b/Tests/NimbleTests/Matchers/SatisfyAnyOfTest.swift @@ -68,7 +68,7 @@ final class SatisfyAnyOfTest: XCTestCase { #endif // MARK: - Async Variant - @available(macOSApplicationExtension 13.0.0, macOS 13.0.0, iOS 16.0.0, tvOS 16.0.0, watchOS 9.0.0, *) + @available(macOS 13.0.0, iOS 16.0.0, tvOS 16.0.0, watchOS 9.0.0, *) func testAsyncSatisfyAnyOf() async { await expect(2).to(satisfyAnyOf(asyncEqual(2), asyncEqual(3))) await expect(2 as NSNumber).toNot(satisfyAnyOf(asyncEqual(3 as NSNumber), asyncEqual("turtles" as NSString))) @@ -104,7 +104,7 @@ final class SatisfyAnyOfTest: XCTestCase { } } - @available(macOSApplicationExtension 13.0.0, macOS 13.0.0, iOS 16.0.0, tvOS 16.0.0, watchOS 9.0.0, *) + @available(macOS 13.0.0, iOS 16.0.0, tvOS 16.0.0, watchOS 9.0.0, *) func testAsyncOperatorOr() async { await expect(2).to(asyncEqual(2) || asyncEqual(3)) await expect(2 as NSNumber).toNot(asyncEqual(3 as NSNumber) || asyncEqual("turtles" as NSString)) @@ -115,7 +115,7 @@ final class SatisfyAnyOfTest: XCTestCase { } #if !os(WASI) - @available(macOSApplicationExtension 13.0.0, macOS 13.0.0, iOS 16.0.0, tvOS 16.0.0, watchOS 9.0.0, *) + @available(macOS 13.0.0, iOS 16.0.0, tvOS 16.0.0, watchOS 9.0.0, *) func testAsyncSatisfyAllOfCachesExpressionBeforePassingToPredicates() async { // This is not a great example of assertion writing - functions being asserted on in Expressions should not have side effects. // But we should still handle those cases anyway. From cdd40d2af8ee7f6e87912515afc01b20c1be59ed Mon Sep 17 00:00:00 2001 From: Rachel Brindle Date: Sat, 20 May 2023 22:02:01 -0700 Subject: [PATCH 4/8] just require swift 5.8 for satisfyAny/AllOf with async predicates --- .github/workflows/ci-swiftpm.yml | 15 ++++++++++++++- .github/workflows/ci-xcode.yml | 18 +++++++++++++++++- Sources/Nimble/Matchers/SatisfyAllOf.swift | 7 ++++++- Sources/Nimble/Matchers/SatisfyAnyOf.swift | 7 ++++++- .../Matchers/SatisfyAllOfTest.swift | 7 ++++++- .../Matchers/SatisfyAnyOfTest.swift | 7 ++++++- 6 files changed, 55 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci-swiftpm.yml b/.github/workflows/ci-swiftpm.yml index 8a8a886ad..0ef6ed3ef 100644 --- a/.github/workflows/ci-swiftpm.yml +++ b/.github/workflows/ci-swiftpm.yml @@ -11,7 +11,7 @@ on: - "*" jobs: - swiftpm_darwin: + swiftpm_darwin_monterey: name: SwiftPM, Darwin, Xcode ${{ matrix.xcode }} runs-on: macos-12 strategy: @@ -23,6 +23,18 @@ jobs: - uses: actions/checkout@v3 - run: ./test swiftpm + swiftpm_darwin_ventura: + name: SwiftPM, Darwin, Xcode ${{ matrix.xcode }} + runs-on: macos-13 + strategy: + matrix: + xcode: ["14.3"] + env: + DEVELOPER_DIR: "/Applications/Xcode_${{ matrix.xcode }}.app" + steps: + - uses: actions/checkout@v3 + - run: ./test swiftpm + swiftpm_linux: name: SwiftPM, Linux runs-on: ubuntu-latest @@ -30,6 +42,7 @@ jobs: matrix: container: - swift:5.7 + - swift:5.8 # - swiftlang/swift:nightly fail-fast: false container: ${{ matrix.container }} diff --git a/.github/workflows/ci-xcode.yml b/.github/workflows/ci-xcode.yml index d601a58f8..b1334df59 100644 --- a/.github/workflows/ci-xcode.yml +++ b/.github/workflows/ci-xcode.yml @@ -11,7 +11,7 @@ on: - "*" jobs: - xcode: + xcode_monterey: name: Xcode ${{ matrix.xcode }} (Xcode Project) runs-on: macos-12 strategy: @@ -27,6 +27,22 @@ jobs: - run: ./test tvos - run: ./test watchos + xcode_ventura: + name: Xcode ${{ matrix.xcode }} (Xcode Project) + runs-on: macos-12 + strategy: + matrix: + xcode: ["14.3"] + fail-fast: false + env: + DEVELOPER_DIR: "/Applications/Xcode_${{ matrix.xcode }}.app" + steps: + - uses: actions/checkout@v3 + - run: ./test macos + - run: ./test ios + - run: ./test tvos + - run: ./test watchos + xcode_spm: name: Xcode ${{ matrix.xcode }} (Swift Package) runs-on: macos-12 diff --git a/Sources/Nimble/Matchers/SatisfyAllOf.swift b/Sources/Nimble/Matchers/SatisfyAllOf.swift index 5e883fe88..e95d3fe54 100644 --- a/Sources/Nimble/Matchers/SatisfyAllOf.swift +++ b/Sources/Nimble/Matchers/SatisfyAllOf.swift @@ -41,6 +41,10 @@ public func && (left: Predicate, right: Predicate) -> Predicate { return satisfyAllOf(left, right) } +// There's a compiler bug in swift 5.7.2 and earlier (xcode 14.2 and earlier) +// which causes runtime crashes when you use `[any AsyncablePredicate]`. +// https://github.com/apple/swift/issues/61403 +#if swift(>=5.8.0) /// A Nimble matcher that succeeds when the actual value matches with all of the matchers /// provided in the variable list of matchers. @available(macOS 13.0.0, iOS 16.0.0, tvOS 16.0.0, watchOS 9.0.0, *) @@ -83,9 +87,10 @@ public func satisfyAllOf(_ predicates: [any AsyncablePredicate]) -> AsyncP } @available(macOS 13.0.0, iOS 16.0.0, tvOS 16.0.0, watchOS 9.0.0, *) -public func && (left: any AsyncablePredicate, right: any AsyncablePredicate) -> AsyncPredicate { +public func && (left: some AsyncablePredicate, right: some AsyncablePredicate) -> AsyncPredicate { return satisfyAllOf(left, right) } +#endif // swift(>=5.8.0) #if canImport(Darwin) import class Foundation.NSObject diff --git a/Sources/Nimble/Matchers/SatisfyAnyOf.swift b/Sources/Nimble/Matchers/SatisfyAnyOf.swift index 092b5c52c..19109dee4 100644 --- a/Sources/Nimble/Matchers/SatisfyAnyOf.swift +++ b/Sources/Nimble/Matchers/SatisfyAnyOf.swift @@ -41,6 +41,10 @@ public func || (left: Predicate, right: Predicate) -> Predicate { return satisfyAnyOf(left, right) } +// There's a compiler bug in swift 5.7.2 and earlier (xcode 14.2 and earlier) +// which causes runtime crashes when you use `[any AsyncablePredicate]`. +// https://github.com/apple/swift/issues/61403 +#if swift(>=5.8.0) /// A Nimble matcher that succeeds when the actual value matches with any of the matchers /// provided in the variable list of matchers. @available(macOS 13.0.0, iOS 16.0.0, tvOS 16.0.0, watchOS 9.0.0, *) @@ -83,9 +87,10 @@ public func satisfyAnyOf(_ predicates: [any AsyncablePredicate]) -> AsyncP } @available(macOS 13.0.0, iOS 16.0.0, tvOS 16.0.0, watchOS 9.0.0, *) -public func || (left: any AsyncablePredicate, right: any AsyncablePredicate) -> AsyncPredicate { +public func || (left: some AsyncablePredicate, right: some AsyncablePredicate) -> AsyncPredicate { return satisfyAnyOf(left, right) } +#endif // swift(>=5.8.0) #if canImport(Darwin) import class Foundation.NSObject diff --git a/Tests/NimbleTests/Matchers/SatisfyAllOfTest.swift b/Tests/NimbleTests/Matchers/SatisfyAllOfTest.swift index 3eee1cca4..b924cae01 100644 --- a/Tests/NimbleTests/Matchers/SatisfyAllOfTest.swift +++ b/Tests/NimbleTests/Matchers/SatisfyAllOfTest.swift @@ -67,6 +67,10 @@ final class SatisfyAllOfTest: XCTestCase { } #endif + // There's a compiler bug in swift 5.7.2 and earlier (xcode 14.2 and earlier) + // which causes runtime crashes when you use `[any AsyncablePredicate]`. + // https://github.com/apple/swift/issues/61403 + #if swift(>=5.8.0) // MARK: - AsyncPredicate variant @available(macOS 13.0.0, iOS 16.0.0, tvOS 16.0.0, watchOS 9.0.0, *) func testAsyncSatisfyAllOf() async { @@ -133,5 +137,6 @@ final class SatisfyAllOfTest: XCTestCase { await expecta(await counter.increment()).toEventually(satisfyAllOf(asyncEqual(1), asyncEqual(1))) } - #endif + #endif // !os(WASI) + #endif // swift(>=5.8.0) } diff --git a/Tests/NimbleTests/Matchers/SatisfyAnyOfTest.swift b/Tests/NimbleTests/Matchers/SatisfyAnyOfTest.swift index 8da6678a5..56c6a4348 100644 --- a/Tests/NimbleTests/Matchers/SatisfyAnyOfTest.swift +++ b/Tests/NimbleTests/Matchers/SatisfyAnyOfTest.swift @@ -67,6 +67,10 @@ final class SatisfyAnyOfTest: XCTestCase { } #endif + // There's a compiler bug in swift 5.7 and earlier (xcode 14.2 and earlier) + // which causes runtime crashes when you use `[any AsyncablePredicate]`. + // https://github.com/apple/swift/issues/61403 + #if swift(>=5.8.0) // MARK: - Async Variant @available(macOS 13.0.0, iOS 16.0.0, tvOS 16.0.0, watchOS 9.0.0, *) func testAsyncSatisfyAnyOf() async { @@ -133,5 +137,6 @@ final class SatisfyAnyOfTest: XCTestCase { // Next time, it'll return 2, which doesn't pass the `equal(1)`. await expecta(await counter.increment()).toEventually(satisfyAnyOf(asyncEqual(0), asyncEqual(1))) } - #endif + #endif // !os(WASI) + #endif // swift(>=5.8.0) } From 8555a8b890825dd90387a0e48d2fcbfbee9acb8f Mon Sep 17 00:00:00 2001 From: Rachel Brindle Date: Sun, 21 May 2023 19:33:01 -0700 Subject: [PATCH 5/8] xcode 14.3 requires macos 13 --- .github/workflows/ci-swiftpm.yml | 2 +- .github/workflows/ci-xcode.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci-swiftpm.yml b/.github/workflows/ci-swiftpm.yml index 0ef6ed3ef..25ebba283 100644 --- a/.github/workflows/ci-swiftpm.yml +++ b/.github/workflows/ci-swiftpm.yml @@ -28,7 +28,7 @@ jobs: runs-on: macos-13 strategy: matrix: - xcode: ["14.3"] + xcode: ["14.3.1"] env: DEVELOPER_DIR: "/Applications/Xcode_${{ matrix.xcode }}.app" steps: diff --git a/.github/workflows/ci-xcode.yml b/.github/workflows/ci-xcode.yml index b1334df59..9e2f6f03b 100644 --- a/.github/workflows/ci-xcode.yml +++ b/.github/workflows/ci-xcode.yml @@ -29,10 +29,10 @@ jobs: xcode_ventura: name: Xcode ${{ matrix.xcode }} (Xcode Project) - runs-on: macos-12 + runs-on: macos-13 strategy: matrix: - xcode: ["14.3"] + xcode: ["14.3.1"] fail-fast: false env: DEVELOPER_DIR: "/Applications/Xcode_${{ matrix.xcode }}.app" From 75e542a5dd02f0d5cca96eb3b9c642d046b43d18 Mon Sep 17 00:00:00 2001 From: Rachel Brindle Date: Wed, 12 Jul 2023 16:14:25 -0700 Subject: [PATCH 6/8] Be more discerning when trying to find watchOS and macOS SDK versions --- test | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/test b/test index d1759552f..a2999e3ec 100755 --- a/test +++ b/test @@ -10,9 +10,9 @@ if which xcodebuild > /dev/null; then LATEST_IOS_VERSION=`xcrun simctl list | grep ^iOS | ruby -e 'puts /\(([0-9.]+).*\)/.match(STDIN.read.chomp.split("\n").last).to_a[1]'` LATEST_TVOS_SDK_VERSION=`xcodebuild -showsdks | grep appletvsimulator | cut -d ' ' -f 4 | ruby -e 'puts STDIN.read.chomp.split("\n").last'` LATEST_TVOS_VERSION=`xcrun simctl list | grep ^tvOS | ruby -e 'puts /\(([0-9.]+).*\)/.match(STDIN.read.chomp.split("\n").last).to_a[1]'` - LATEST_WATCHOS_SDK_VERSION=`xcodebuild -showsdks | grep watchos | cut -d ' ' -f 2 | ruby -e 'puts STDIN.read.chomp.split("\n").last'` + LATEST_WATCHOS_SDK_VERSION=`xcodebuild -showsdks | grep -e '-sdk watchos' | cut -d ' ' -f 2 | ruby -e 'puts STDIN.read.chomp.split("\n").last'` LATEST_WATCHOS_VERSION=`xcrun simctl list | grep ^watchOS | ruby -e 'puts /\(([0-9.]+).*\)/.match(STDIN.read.chomp.split("\n").last).to_a[1]'` - LATEST_MACOS_SDK_VERSION=`xcodebuild -showsdks | grep 'macosx' | cut -d ' ' -f 2 | ruby -e 'puts STDIN.read.chomp.split("\n").last'` + LATEST_MACOS_SDK_VERSION=`xcodebuild -showsdks | grep -e '-sdk macosx' | cut -d ' ' -f 2 | ruby -e 'puts STDIN.read.chomp.split("\n").last'` BUILD_IOS_SDK_VERSION=${NIMBLE_BUILD_IOS_SDK_VERSION:-$LATEST_IOS_SDK_VERSION} RUNTIME_IOS_VERSION=${NIMBLE_RUNTIME_IOS_VERSION:-$LATEST_IOS_VERSION} BUILD_TVOS_SDK_VERSION=${NIMBLE_BUILD_TVOS_SDK_VERSION:-$LATEST_TVOS_SDK_VERSION} @@ -22,6 +22,10 @@ if which xcodebuild > /dev/null; then BUILD_MACOS_SDK_VERSION=${NIMBLE_BUILD_MACOS_SDK_VERSION:-$LATEST_MACOS_SDK_VERSION} fi +echo "Debug environment printing:" +xcrun simctl list +echo "End debug environment printing" + set -e function color_if_overridden { From ff8973461f324ce3c9af2c2af7c8b90a9f0c7aa2 Mon Sep 17 00:00:00 2001 From: Rachel Brindle Date: Wed, 12 Jul 2023 20:32:39 -0700 Subject: [PATCH 7/8] Use a more recent iPhone for testing --- test | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/test b/test index a2999e3ec..dedd8363b 100755 --- a/test +++ b/test @@ -22,10 +22,6 @@ if which xcodebuild > /dev/null; then BUILD_MACOS_SDK_VERSION=${NIMBLE_BUILD_MACOS_SDK_VERSION:-$LATEST_MACOS_SDK_VERSION} fi -echo "Debug environment printing:" -xcrun simctl list -echo "End debug environment printing" - set -e function color_if_overridden { @@ -72,7 +68,7 @@ function test_ios { run set -o pipefail && xcodebuild -project Nimble.xcodeproj -scheme "Nimble-iOS" -configuration "Debug" -destination "generic/platform=iOS" OTHER_SWIFT_FLAGS='$(inherited) -suppress-warnings' build | xcpretty run osascript -e 'tell app "Simulator" to quit' - run set -o pipefail && xcodebuild -project Nimble.xcodeproj -scheme "Nimble-iOS" -configuration "Debug" -sdk "iphonesimulator$BUILD_IOS_SDK_VERSION" -destination "name=iPhone 8,OS=$RUNTIME_IOS_VERSION" OTHER_SWIFT_FLAGS='$(inherited) -suppress-warnings' build-for-testing test-without-building | xcpretty + run set -o pipefail && xcodebuild -project Nimble.xcodeproj -scheme "Nimble-iOS" -configuration "Debug" -sdk "iphonesimulator$BUILD_IOS_SDK_VERSION" -destination "name=iPhone SE (3rd generation),OS=$RUNTIME_IOS_VERSION" OTHER_SWIFT_FLAGS='$(inherited) -suppress-warnings' build-for-testing test-without-building | xcpretty } function test_tvos { @@ -103,7 +99,7 @@ function test_xcode_spm_ios { run osascript -e 'tell app "Simulator" to quit' mv Nimble.xcodeproj Nimble.xcodeproj.bak trap 'mv Nimble.xcodeproj.bak Nimble.xcodeproj' EXIT - run set -o pipefail && xcodebuild -scheme "Nimble" -configuration "Debug" -sdk "iphonesimulator$BUILD_IOS_SDK_VERSION" -destination "name=iPhone 8,OS=$RUNTIME_IOS_VERSION" OTHER_SWIFT_FLAGS='$(inherited) -suppress-warnings' build-for-testing test-without-building | xcpretty + run set -o pipefail && xcodebuild -scheme "Nimble" -configuration "Debug" -sdk "iphonesimulator$BUILD_IOS_SDK_VERSION" -destination "name=iPhone SE (3rd generation),OS=$RUNTIME_IOS_VERSION" OTHER_SWIFT_FLAGS='$(inherited) -suppress-warnings' build-for-testing test-without-building | xcpretty } function test_xcode_spm_tvos { From 0b475d861ab252c6470ca07e1291e52771cec82c Mon Sep 17 00:00:00 2001 From: Rachel Brindle Date: Wed, 12 Jul 2023 21:48:20 -0700 Subject: [PATCH 8/8] Add an async version of allPass. Update documentation to mention async predicates --- Nimble.xcodeproj/project.pbxproj | 20 +++ README.md | 70 ++++++++- Sources/Nimble/Matchers/AsyncAllPass.swift | 64 +++++++++ .../Matchers/AsyncAllPassTest.swift | 134 ++++++++++++++++++ 4 files changed, 287 insertions(+), 1 deletion(-) create mode 100644 Sources/Nimble/Matchers/AsyncAllPass.swift create mode 100644 Tests/NimbleTests/Matchers/AsyncAllPassTest.swift diff --git a/Nimble.xcodeproj/project.pbxproj b/Nimble.xcodeproj/project.pbxproj index c864fa524..d100009e4 100644 --- a/Nimble.xcodeproj/project.pbxproj +++ b/Nimble.xcodeproj/project.pbxproj @@ -358,6 +358,14 @@ 892FDF1429D3EA7700523A80 /* AsyncExpression.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892FDF1229D3EA7700523A80 /* AsyncExpression.swift */; }; 892FDF1529D3EA7700523A80 /* AsyncExpression.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892FDF1229D3EA7700523A80 /* AsyncExpression.swift */; }; 892FDF1629D3EA7700523A80 /* AsyncExpression.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892FDF1229D3EA7700523A80 /* AsyncExpression.swift */; }; + 896962412A5FABD000A7929D /* AsyncAllPass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 896962402A5FABD000A7929D /* AsyncAllPass.swift */; }; + 896962422A5FABD000A7929D /* AsyncAllPass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 896962402A5FABD000A7929D /* AsyncAllPass.swift */; }; + 896962432A5FABD000A7929D /* AsyncAllPass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 896962402A5FABD000A7929D /* AsyncAllPass.swift */; }; + 896962442A5FABD000A7929D /* AsyncAllPass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 896962402A5FABD000A7929D /* AsyncAllPass.swift */; }; + 8969624A2A5FAD5F00A7929D /* AsyncAllPassTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 896962452A5FAD4500A7929D /* AsyncAllPassTest.swift */; }; + 8969624B2A5FAD6000A7929D /* AsyncAllPassTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 896962452A5FAD4500A7929D /* AsyncAllPassTest.swift */; }; + 8969624C2A5FAD6100A7929D /* AsyncAllPassTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 896962452A5FAD4500A7929D /* AsyncAllPassTest.swift */; }; + 8969624D2A5FAD6300A7929D /* AsyncAllPassTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 896962452A5FAD4500A7929D /* AsyncAllPassTest.swift */; }; 898F28B025D9F4C30052B8D0 /* AlwaysFailMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 898F28AF25D9F4C30052B8D0 /* AlwaysFailMatcher.swift */; }; 898F28B125D9F4C30052B8D0 /* AlwaysFailMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 898F28AF25D9F4C30052B8D0 /* AlwaysFailMatcher.swift */; }; 898F28B225D9F4C30052B8D0 /* AlwaysFailMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 898F28AF25D9F4C30052B8D0 /* AlwaysFailMatcher.swift */; }; @@ -777,6 +785,8 @@ 857D1848253610A900D8693A /* BeWithin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeWithin.swift; sourceTree = ""; }; 857D184D2536123F00D8693A /* BeWithinTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeWithinTest.swift; sourceTree = ""; }; 892FDF1229D3EA7700523A80 /* AsyncExpression.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AsyncExpression.swift; sourceTree = ""; }; + 896962402A5FABD000A7929D /* AsyncAllPass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncAllPass.swift; sourceTree = ""; }; + 896962452A5FAD4500A7929D /* AsyncAllPassTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncAllPassTest.swift; sourceTree = ""; }; 898F28AF25D9F4C30052B8D0 /* AlwaysFailMatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlwaysFailMatcher.swift; sourceTree = ""; }; 899441EE2902EE4B00C1FAF9 /* AsyncAwaitTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncAwaitTest.swift; sourceTree = ""; }; 899441F32902EF0900C1FAF9 /* DSL+AsyncAwait.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DSL+AsyncAwait.swift"; sourceTree = ""; }; @@ -1010,6 +1020,7 @@ children = ( DD72EC631A93874A002F7651 /* AllPassTest.swift */, 898F28AF25D9F4C30052B8D0 /* AlwaysFailMatcher.swift */, + 896962452A5FAD4500A7929D /* AsyncAllPassTest.swift */, 89EEF5B22A032C2500988224 /* AsyncPredicateTest.swift */, 1F1B5AD31963E13900CA8BF9 /* BeAKindOfTest.swift */, 1F925EE8195C124400ED456B /* BeAnInstanceOfTest.swift */, @@ -1066,6 +1077,7 @@ isa = PBXGroup; children = ( DDB1BC781A92235600F743C3 /* AllPass.swift */, + 896962402A5FABD000A7929D /* AsyncAllPass.swift */, 89EEF5A42A03293100988224 /* AsyncPredicate.swift */, 1FD8CD0E1968AB07008ED995 /* BeAKindOf.swift */, 1FD8CD0D1968AB07008ED995 /* BeAnInstanceOf.swift */, @@ -1684,6 +1696,7 @@ CDFB6A3A1F7E082500AD8CC7 /* CwlBadInstructionException.swift in Sources */, 7B5358BE1C38479700A23FAA /* SatisfyAnyOf.swift in Sources */, CDFB6A261F7E07C700AD8CC7 /* CwlCatchException.m in Sources */, + 896962422A5FABD000A7929D /* AsyncAllPass.swift in Sources */, 1FD8CD381968AB07008ED995 /* Expression.swift in Sources */, 1FD8CD3A1968AB07008ED995 /* FailureMessage.swift in Sources */, CDFB6A4C1F7E082500AD8CC7 /* mach_excServer.c in Sources */, @@ -1709,6 +1722,7 @@ 1F1B5AD41963E13900CA8BF9 /* BeAKindOfTest.swift in Sources */, 1F925F0E195C18F500ED456B /* BeLessThanOrEqualToTest.swift in Sources */, CDBC39BA2462EA7D00069677 /* PredicateTest.swift in Sources */, + 8969624B2A5FAD6000A7929D /* AsyncAllPassTest.swift in Sources */, 1F4A56661A3B305F009E1637 /* ObjCAsyncTest.m in Sources */, 1F925EFC195C186800ED456B /* BeginWithTest.swift in Sources */, 89F5E06E290765BB001F9377 /* PollingTest.swift in Sources */, @@ -1797,6 +1811,7 @@ 1F5DF18A1BDCA0F500C3A531 /* ThrowError.swift in Sources */, 89F5E08E290B8D22001F9377 /* AsyncAwait.swift in Sources */, 1F5DF1891BDCA0F500C3A531 /* RaisesException.swift in Sources */, + 896962432A5FABD000A7929D /* AsyncAllPass.swift in Sources */, 1F5DF1761BDCA0F500C3A531 /* AllPass.swift in Sources */, AE4BA9AF1C88DDB500B73906 /* Errors.swift in Sources */, 1F5DF1861BDCA0F500C3A531 /* HaveCount.swift in Sources */, @@ -1865,6 +1880,7 @@ CDBC39BB2462EA7D00069677 /* PredicateTest.swift in Sources */, CD79C9B21D2CC848004B6F9A /* ObjCHaveCountTest.m in Sources */, CD79C9A41D2CC848004B6F9A /* ObjCBeFalsyTest.m in Sources */, + 8969624C2A5FAD6100A7929D /* AsyncAllPassTest.swift in Sources */, 1F5DF1981BDCA10200C3A531 /* BeAKindOfTest.swift in Sources */, 1F5DF19B1BDCA10200C3A531 /* BeEmptyTest.swift in Sources */, 7B5358BC1C3846C900A23FAA /* SatisfyAnyOfTest.swift in Sources */, @@ -2001,6 +2017,7 @@ 1FD8CD351968AB07008ED995 /* DSL.swift in Sources */, CDFB6A391F7E082500AD8CC7 /* CwlBadInstructionException.swift in Sources */, 7B5358BF1C38479700A23FAA /* SatisfyAnyOf.swift in Sources */, + 896962412A5FABD000A7929D /* AsyncAllPass.swift in Sources */, CDFB6A251F7E07C700AD8CC7 /* CwlCatchException.m in Sources */, 1FD8CD391968AB07008ED995 /* Expression.swift in Sources */, CDFB6A4B1F7E082500AD8CC7 /* mach_excServer.c in Sources */, @@ -2026,6 +2043,7 @@ 1F1B5AD51963E13900CA8BF9 /* BeAKindOfTest.swift in Sources */, 1F925F0F195C18F500ED456B /* BeLessThanOrEqualToTest.swift in Sources */, CDBC39B92462EA7D00069677 /* PredicateTest.swift in Sources */, + 8969624A2A5FAD5F00A7929D /* AsyncAllPassTest.swift in Sources */, 1F4A56671A3B305F009E1637 /* ObjCAsyncTest.m in Sources */, 1F925EFD195C186800ED456B /* BeginWithTest.swift in Sources */, 89F5E06D290765BB001F9377 /* PollingTest.swift in Sources */, @@ -2114,6 +2132,7 @@ D95F8976267EA20A004B1B4D /* BeGreaterThanOrEqualTo.swift in Sources */, 89F5E08F290B8D22001F9377 /* AsyncAwait.swift in Sources */, D95F8958267EA1F7004B1B4D /* AssertionRecorder.swift in Sources */, + 896962442A5FABD000A7929D /* AsyncAllPass.swift in Sources */, D95F897E267EA20A004B1B4D /* BeWithin.swift in Sources */, D95F8928267EA1CA004B1B4D /* NMBExceptionCapture.m in Sources */, D95F8981267EA20A004B1B4D /* BeginWith.swift in Sources */, @@ -2182,6 +2201,7 @@ D95F893B267EA1E8004B1B4D /* BeLogicalTest.swift in Sources */, 891364AE29E695F300AD535E /* ObjCBeFalsyTest.m in Sources */, D95F894D267EA1E8004B1B4D /* ElementsEqualTest.swift in Sources */, + 8969624D2A5FAD6300A7929D /* AsyncAllPassTest.swift in Sources */, D95F8939267EA1E8004B1B4D /* BeIdenticalToObjectTest.swift in Sources */, 891364AC29E695F300AD535E /* ObjCBeCloseToTest.m in Sources */, 899441F22902EE4B00C1FAF9 /* AsyncAwaitTest.swift in Sources */, diff --git a/README.md b/README.md index 20c0477ed..588eca4ea 100644 --- a/README.md +++ b/README.md @@ -324,7 +324,7 @@ To avoid a compiler errors when using synchronous `expect` in asynchronous conte ```swift // Swift -await expect(await aFunctionReturning1()).to(equal(1))) +await expecta(await aFunctionReturning1()).to(equal(1))) ``` Similarly, if you're ever in a situation where you want to force the compiler to @@ -338,6 +338,22 @@ expects(someNonAsyncFunction()).to(equal(1))) expects(await someAsyncFunction()).to(equal(1)) // Compiler error: 'async' call in an autoclosure that does not support concurrency ``` +### Async Matchers + +In addition to asserting on async functions prior to passing them to a +synchronous predicate, you can also write matchers that directly take in an +async value. These are called `AsyncPredicate`s. This is most obviously useful +when directly asserting against an actor. In addition to writing your own +async matchers, Nimble currently ships with async versions of the following +predicates: + +- `allPass` +- `containElementSatisfying` +- `satisfyAllOf` and the `&&` operator overload accept both `AsyncPredicate` and + synchronous `Predicate`s. +- `satisfyAnyOf` and the `||` operator overload accept both `AsyncPredicate` and + synchronous `Predicate`s. + Note: Async/Await support is different than the `toEventually`/`toEventuallyNot` feature described below. @@ -1193,6 +1209,9 @@ expect(turtles).to(containElementSatisfying({ turtle in // should it fail ``` +Note: in Swift, `containElementSatisfying` also has a variant that takes in an +async function. + ```objc // Objective-C @@ -1287,6 +1306,19 @@ expect([1, 2, 3, 4]).to(allPass { $0 < 5 }) expect([1, 2, 3, 4]).to(allPass(beLessThan(5))) ``` +There are also variants of `allPass` that check against async matchers, and +that take in async functions: + +```swift +// Swift + +// Providing a custom function: +expect([1, 2, 3, 4]).to(allPass { await asyncFunctionReturningBool($0) }) + +// Composing the expectation with another matcher: +expect([1, 2, 3, 4]).to(allPass(someAsyncMatcher())) +``` + ### Objective-C In Objective-C, the collection must be an instance of a type which implements @@ -1414,6 +1446,9 @@ expect(6).to(satisfyAnyOf(equal(2), equal(3), equal(4), equal(5), equal(6), equa expect(82).to(beLessThan(50) || beGreaterThan(80)) ``` +Note: In swift, you can mix and match synchronous and asynchronous predicates +using by `satisfyAnyOf`/`||`. + ```objc // Objective-C @@ -1709,6 +1744,39 @@ For a more comprehensive message that spans multiple lines, use .expectedActualValueTo("be true").appended(details: "use beFalse() for inverse\nor use beNil()") ``` +## Asynchronous Predicates + +To write predicates against async expressions, return an instance of +`AsyncPredicate`. The closure passed to `AsyncPredicate` is async, and the +expression you evaluate is also asynchronous and needs to be awaited on. + +```swift +// Swift + +actor CallRecorder { + private(set) var calls: [Arguments] = [] + + func record(call: Arguments) { + calls.append(call) + } +} + +func beCalled(with arguments: Argument) -> AsyncPredicate> { + AsyncPredicate { (expression: AsyncExpression>) in + let message = ExpectationMessage.expectedActualValueTo("be called with \(arguments)") + guard let calls = try await expression.evaluate()?.calls else { + return PredicateResult(status: .fail, message: message.appendedBeNilHint()) + } + + return PredicateResult(bool: calls.contains(args), message: message.appended(details: "called with \(calls)")) + } +} +``` + +In this example, we created an actor to act as an object to record calls to an +async function. Then, we created the `beCalled(with:)` matcher to check if the +actor has received a call with the given arguments. + ## Supporting Objective-C To use a custom matcher written in Swift from Objective-C, you'll have diff --git a/Sources/Nimble/Matchers/AsyncAllPass.swift b/Sources/Nimble/Matchers/AsyncAllPass.swift new file mode 100644 index 000000000..928ca3434 --- /dev/null +++ b/Sources/Nimble/Matchers/AsyncAllPass.swift @@ -0,0 +1,64 @@ +public func allPass( + _ passFunc: @escaping (S.Element) async throws -> Bool +) -> AsyncPredicate { + let matcher = AsyncPredicate.define("pass a condition") { actualExpression, message in + guard let actual = try await actualExpression.evaluate() else { + return PredicateResult(status: .fail, message: message) + } + return PredicateResult(bool: try await passFunc(actual), message: message) + } + return createPredicate(matcher) +} + +public func allPass( + _ passName: String, + _ passFunc: @escaping (S.Element) async throws -> Bool +) -> AsyncPredicate { + let matcher = AsyncPredicate.define(passName) { actualExpression, message in + guard let actual = try await actualExpression.evaluate() else { + return PredicateResult(status: .fail, message: message) + } + return PredicateResult(bool: try await passFunc(actual), message: message) + } + return createPredicate(matcher) +} + +public func allPass(_ elementPredicate: AsyncPredicate) -> AsyncPredicate { + return createPredicate(elementPredicate) +} + +private func createPredicate(_ elementMatcher: AsyncPredicate) -> AsyncPredicate { + return AsyncPredicate { actualExpression in + guard let actualValue = try await actualExpression.evaluate() else { + return PredicateResult( + status: .fail, + message: .appends(.expectedTo("all pass"), " (use beNil() to match nils)") + ) + } + + var failure: ExpectationMessage = .expectedTo("all pass") + for currentElement in actualValue { + let exp = AsyncExpression( + expression: { currentElement }, + location: actualExpression.location + ) + let predicateResult = try await elementMatcher.satisfies(exp) + if predicateResult.status == .matches { + failure = predicateResult.message.prepended(expectation: "all ") + } else { + failure = predicateResult.message + .replacedExpectation({ .expectedTo($0.expectedMessage) }) + .wrappedExpectation( + before: "all ", + after: ", but failed first at element <\(stringify(currentElement))>" + + " in <\(stringify(actualValue))>" + ) + return PredicateResult(status: .doesNotMatch, message: failure) + } + } + failure = failure.replacedExpectation({ expectation in + return .expectedTo(expectation.expectedMessage) + }) + return PredicateResult(status: .matches, message: failure) + } +} diff --git a/Tests/NimbleTests/Matchers/AsyncAllPassTest.swift b/Tests/NimbleTests/Matchers/AsyncAllPassTest.swift new file mode 100644 index 000000000..0baf7b367 --- /dev/null +++ b/Tests/NimbleTests/Matchers/AsyncAllPassTest.swift @@ -0,0 +1,134 @@ +import XCTest +import Nimble +#if SWIFT_PACKAGE +import NimbleSharedTestHelpers +#endif + +private func asyncCheck(_ closure: () -> Bool) async -> Bool { + closure() +} + +private func asyncBeLessThan(_ expectedValue: T?) -> AsyncPredicate { + let message = "be less than <\(stringify(expectedValue))>" + return AsyncPredicate.simple(message) { actualExpression in + guard let actual = try await actualExpression.evaluate(), let expected = expectedValue else { return .fail } + + return PredicateStatus(bool: actual < expected) + } +} + +private func asyncBeGreaterThan(_ expectedValue: T?) -> AsyncPredicate { + let message = "be greater than <\(stringify(expectedValue))>" + return AsyncPredicate.simple(message) { actualExpression in + guard let actual = try await actualExpression.evaluate(), let expected = expectedValue else { return .fail } + + return PredicateStatus(bool: actual > expected) + } +} + +private func asyncBeNil() -> AsyncPredicate { + return AsyncPredicate.simpleNilable("be nil") { actualExpression in + let actualValue = try await actualExpression.evaluate() + return PredicateStatus(bool: actualValue == nil) + } +} + +final class AsyncAllPassTest: XCTestCase { + func testAllPassArray() async { + await expect([1, 2, 3, 4]).to(allPass { value in + await asyncCheck { value < 5 } + }) + await expect([1, 2, 3, 4]).toNot(allPass { value in + await asyncCheck { value > 5 } + }) + + await failsWithErrorMessage( + "expected to all pass a condition, but failed first at element <3> in <[1, 2, 3, 4]>") { + await expect([1, 2, 3, 4]).to(allPass { value in + await asyncCheck { value < 3 } + + }) + } + await failsWithErrorMessage("expected to not all pass a condition") { + await expect([1, 2, 3, 4]).toNot(allPass { value in + await asyncCheck { value < 5 } + + }) + } + await failsWithErrorMessage( + "expected to all be something, but failed first at element <3> in <[1, 2, 3, 4]>") { + await expect([1, 2, 3, 4]).to(allPass("be something", { value in + await asyncCheck { value < 3 } + })) + } + await failsWithErrorMessage("expected to not all be something") { + await expect([1, 2, 3, 4]).toNot(allPass("be something", { value in + await asyncCheck { value < 5 } + })) + } + } + + func testAllPassMatcher() async { + await expect([1, 2, 3, 4]).to(allPass(asyncBeLessThan(5))) + await expect([1, 2, 3, 4]).toNot(allPass(asyncBeGreaterThan(5))) + + await failsWithErrorMessage( + "expected to all be less than <3>, but failed first at element <3> in <[1, 2, 3, 4]>") { + await expect([1, 2, 3, 4]).to(allPass(asyncBeLessThan(3))) + } + await failsWithErrorMessage("expected to not all be less than <5>") { + await expect([1, 2, 3, 4]).toNot(allPass(asyncBeLessThan(5))) + } + } + + func testAllPassCollectionsWithOptionals() async { + await expect([nil, nil, nil] as [Int?]).to(allPass(asyncBeNil())) + await expect([nil, nil, nil] as [Int?]).to(allPass { value in + await asyncCheck { value == nil } + }) + await expect([nil, 1, nil] as [Int?]).toNot(allPass { value in + await asyncCheck { value == nil } + }) + await expect([1, 1, 1] as [Int?]).to(allPass { value in + await asyncCheck { value == 1 } + }) + await expect([1, 1, nil] as [Int?]).toNot(allPass { value in + await asyncCheck { value == 1 } + }) + await expect([1, 2, 3] as [Int?]).to(allPass { value in + await asyncCheck { value < 4 } + }) + await expect([1, 2, 3] as [Int?]).toNot(allPass { value in + await asyncCheck { value < 3 } + }) + await expect([1, 2, nil] as [Int?]).to(allPass { value in + await asyncCheck { value < 3 } + }) + } + + func testAllPassSet() async { + await expect(Set([1, 2, 3, 4])).to(allPass { value in + await asyncCheck {value < 5 } + }) + await expect(Set([1, 2, 3, 4])).toNot(allPass { value in + await asyncCheck {value > 5 } + }) + + await failsWithErrorMessage("expected to not all pass a condition") { + await expect(Set([1, 2, 3, 4])).toNot(allPass { value in + await asyncCheck {value < 5 } + }) + } + await failsWithErrorMessage("expected to not all be something") { + await expect(Set([1, 2, 3, 4])).toNot(allPass("be something") { value in + await asyncCheck {value < 5 } + }) + } + } + + func testAllPassWithNilAsExpectedValue() async { + await failsWithErrorMessageForNil("expected to all pass") { + await expect(nil as [Int]?).to(allPass(asyncBeLessThan(5))) + } + } +}