diff --git a/Generator/Source/CuckooGeneratorFramework/Templates/VerificationProxyTemplate.swift b/Generator/Source/CuckooGeneratorFramework/Templates/VerificationProxyTemplate.swift index 24a2b58c..453c3c62 100644 --- a/Generator/Source/CuckooGeneratorFramework/Templates/VerificationProxyTemplate.swift +++ b/Generator/Source/CuckooGeneratorFramework/Templates/VerificationProxyTemplate.swift @@ -12,21 +12,27 @@ extension Templates { {{ container.accessibility }} struct __VerificationProxy_{{ container.name }}: Cuckoo.VerificationProxy { private let cuckoo_manager: Cuckoo.MockManager private let callMatcher: Cuckoo.CallMatcher + private let continuation: Cuckoo.Continuation private let sourceLocation: Cuckoo.SourceLocation - {{ container.accessibility }} init(manager: Cuckoo.MockManager, callMatcher: Cuckoo.CallMatcher, sourceLocation: Cuckoo.SourceLocation) { + {{ container.accessibility }} init(manager: Cuckoo.MockManager, callMatcher: Cuckoo.CallMatcher, continuation: Cuckoo.Continuation, sourceLocation: Cuckoo.SourceLocation) { self.cuckoo_manager = manager self.callMatcher = callMatcher + self.continuation = continuation self.sourceLocation = sourceLocation } + {{ container.accessibility }} init(manager: Cuckoo.MockManager, callMatcher: Cuckoo.CallMatcher, sourceLocation: Cuckoo.SourceLocation) { + self.init(manager: manager, callMatcher: callMatcher, continuation: Cuckoo.ContinuationOnlyOnce(), sourceLocation: sourceLocation) + } + {% for property in container.properties %} {{ property.unavailablePlatformsCheck }} {% for attribute in property.attributes %} {{ attribute.text }} {% endfor %} var {{property.name}}: Cuckoo.{{property.verifyType}}<{% if property.isReadOnly %}{{property.type|genericSafe}}{% else %}{{property.nonOptionalType|genericSafe}}{% endif %}> { - return .init(manager: cuckoo_manager, name: "{{property.name}}", callMatcher: callMatcher, sourceLocation: sourceLocation) + return .init(manager: cuckoo_manager, name: "{{property.name}}", callMatcher: callMatcher, continuation: continuation, sourceLocation: sourceLocation) } {% if property.hasUnavailablePlatforms %} #endif @@ -44,7 +50,7 @@ extension Templates { return cuckoo_manager.verify( \"\"\" {{method.fullyQualifiedName}} -\"\"\", callMatcher: callMatcher, parameterMatchers: matchers, sourceLocation: sourceLocation) +\"\"\", callMatcher: callMatcher, parameterMatchers: matchers, continuation: continuation, sourceLocation: sourceLocation) } {% if method.hasUnavailablePlatforms %} #endif diff --git a/Source/Continuation/Continuation.swift b/Source/Continuation/Continuation.swift new file mode 100644 index 00000000..b66a1862 --- /dev/null +++ b/Source/Continuation/Continuation.swift @@ -0,0 +1,54 @@ +// +// Continuation.swift +// Cuckoo +// +// Created by Shoto Kobayashi on 03/09/2022. +// + +import Foundation + +public protocol Continuation { + var exitOnSuccess: Bool { get } + + func check() -> Bool + + func wait() + + func times(_ count: Int) -> VerificationSpec + + func never() -> VerificationSpec + + func atLeastOnce() -> VerificationSpec + + func atLeast(_ count: Int) -> VerificationSpec + + func atMost(_ count: Int) -> VerificationSpec + + func with(_ callMatcher: CallMatcher) -> VerificationSpec +} + +public extension Continuation { + func times(_ count: Int) -> VerificationSpec { + VerificationSpec(callMatcher: Cuckoo.times(count), continuation: self) + } + + func never() -> VerificationSpec { + VerificationSpec(callMatcher: Cuckoo.times(0), continuation: self) + } + + func atLeastOnce() -> VerificationSpec { + VerificationSpec(callMatcher: Cuckoo.atLeast(1), continuation: self) + } + + func atLeast(_ count: Int) -> VerificationSpec { + VerificationSpec(callMatcher: Cuckoo.atLeast(count), continuation: self) + } + + func atMost(_ count: Int) -> VerificationSpec { + VerificationSpec(callMatcher: Cuckoo.atMost(count), continuation: self) + } + + func with(_ callMatcher: CallMatcher) -> VerificationSpec { + VerificationSpec(callMatcher: callMatcher, continuation: self) + } +} diff --git a/Source/Continuation/ContinuationAfterDelay.swift b/Source/Continuation/ContinuationAfterDelay.swift new file mode 100644 index 00000000..7bc66419 --- /dev/null +++ b/Source/Continuation/ContinuationAfterDelay.swift @@ -0,0 +1,17 @@ +// +// ContinuationAfterDelay.swift +// Cuckoo +// +// Created by Shoto Kobayashi on 03/09/2022. +// + + +import Foundation + +public class ContinueationAfterDelay: NSObject, ContinuationWrapper { + public let wrappedContinuation: ContinuationOverTime + + public init(delayDuration: TimeInterval, waitingDuration: TimeInterval) { + wrappedContinuation = ContinuationOverTime(duration: delayDuration, waitingDuration: waitingDuration, exitOnSuccess: false) + } +} diff --git a/Source/Continuation/ContinuationFunctions.swift b/Source/Continuation/ContinuationFunctions.swift new file mode 100644 index 00000000..1b0ca18a --- /dev/null +++ b/Source/Continuation/ContinuationFunctions.swift @@ -0,0 +1,22 @@ +// +// ContinuationFunctions.swift +// Cuckoo +// +// Created by Shoto Kobayashi on 03/09/2022. +// + +import Foundation + +public func timeout(_ timeoutDuration: TimeInterval, waitingDuration: TimeInterval = 0.01) -> ContinuationWithTimeout { + ContinuationWithTimeout( + timeoutDuration: timeoutDuration, + waitingDuration: waitingDuration + ) +} + +public func after(_ delayDuration: TimeInterval, waitingDuration: TimeInterval = 0.01) -> ContinueationAfterDelay { + ContinueationAfterDelay( + delayDuration: delayDuration, + waitingDuration: waitingDuration + ) +} diff --git a/Source/Continuation/ContinuationOnlyOnce.swift b/Source/Continuation/ContinuationOnlyOnce.swift new file mode 100644 index 00000000..810b5001 --- /dev/null +++ b/Source/Continuation/ContinuationOnlyOnce.swift @@ -0,0 +1,29 @@ +// +// ContinuationOnlyOnce.swift +// Cuckoo +// +// Created by Shoto Kobayashi on 03/09/2022. +// + +import Foundation + +public class ContinuationOnlyOnce: NSObject, Continuation { + public let exitOnSuccess = true + + private var isAlreadyChecked = false + + public override init() { + super.init() + } + + public func check() -> Bool { + guard !isAlreadyChecked else { + return false + } + isAlreadyChecked = true + return true + } + + public func wait() { + } +} diff --git a/Source/Continuation/ContinuationOverTime.swift b/Source/Continuation/ContinuationOverTime.swift new file mode 100644 index 00000000..56b4146c --- /dev/null +++ b/Source/Continuation/ContinuationOverTime.swift @@ -0,0 +1,38 @@ +// +// ContinuationOverTime.swift +// Cuckoo +// +// Created by Shoto Kobayashi on 03/09/2022. +// + +import Foundation + +public class ContinuationOverTime: NSObject, Continuation { + public let duration: TimeInterval + public let waitingDuration: TimeInterval + public let exitOnSuccess: Bool + + private var start: Date? + + public init(duration: TimeInterval, waitingDuration: TimeInterval, exitOnSuccess: Bool) { + self.duration = duration + self.waitingDuration = waitingDuration + self.exitOnSuccess = exitOnSuccess + super.init() + } + + public func check() -> Bool { + if start == nil { + start = Date() + } + return -start!.timeIntervalSinceNow <= duration + } + + public func wait() { + Thread.sleep(forTimeInterval: waitingDuration) + } + + public func times(_ count: Int) -> VerificationSpec { + VerificationSpec(callMatcher: Cuckoo.times(count), continuation: self) + } +} diff --git a/Source/Continuation/ContinuationWithTimeout.swift b/Source/Continuation/ContinuationWithTimeout.swift new file mode 100644 index 00000000..d1c1fbc8 --- /dev/null +++ b/Source/Continuation/ContinuationWithTimeout.swift @@ -0,0 +1,16 @@ +// +// ContinuationWithTimeout.swift +// Cuckoo +// +// Created by Shoto Kobayashi on 03/09/2022. +// + +import Foundation + +public class ContinuationWithTimeout: NSObject, ContinuationWrapper { + public let wrappedContinuation: ContinuationOverTime + + public init(timeoutDuration: TimeInterval, waitingDuration: TimeInterval) { + wrappedContinuation = ContinuationOverTime(duration: timeoutDuration, waitingDuration: waitingDuration, exitOnSuccess: true) + } +} diff --git a/Source/Continuation/ContinuationWrapper.swift b/Source/Continuation/ContinuationWrapper.swift new file mode 100644 index 00000000..9a692d16 --- /dev/null +++ b/Source/Continuation/ContinuationWrapper.swift @@ -0,0 +1,28 @@ +// +// ContinuationWrapper.swift +// Cuckoo +// +// Created by Shoto Kobayashi on 03/09/2022. +// + +import Foundation + +public protocol ContinuationWrapper: Continuation { + associatedtype WrappedContinuation: Continuation + + var wrappedContinuation: WrappedContinuation { get } +} + +public extension ContinuationWrapper { + var exitOnSuccess: Bool { + wrappedContinuation.exitOnSuccess + } + + func check() -> Bool { + wrappedContinuation.check() + } + + func wait() { + wrappedContinuation.wait() + } +} diff --git a/Source/CuckooFunctions.swift b/Source/CuckooFunctions.swift index 8a71c4a4..acf0aee7 100644 --- a/Source/CuckooFunctions.swift +++ b/Source/CuckooFunctions.swift @@ -17,9 +17,21 @@ public func when(_ function: F) -> F { return function } +public func verify(_ mock: M, _ callMatcher: CallMatcher, _ continuation: Continuation, file: StaticString = #file, line: UInt = #line) -> M.Verification { + return mock.getVerificationProxy(callMatcher, continuation, sourceLocation: (file, line)) +} + /// Creates object used for verification of calls. public func verify(_ mock: M, _ callMatcher: CallMatcher = times(1), file: StaticString = #file, line: UInt = #line) -> M.Verification { - return mock.getVerificationProxy(callMatcher, sourceLocation: (file, line)) + return verify(mock, callMatcher, ContinuationOnlyOnce(), file: file, line: line) +} + +public func verify(_ mock: M, _ continuation: Continuation, file: StaticString = #file, line: UInt = #line) -> M.Verification { + return verify(mock, times(1), continuation, file: file, line: line) +} + +public func verify(_ mock: M, _ verificationSpec: VerificationSpec, file: StaticString = #file, line: UInt = #line) -> M.Verification { + return verify(mock, verificationSpec.callMatcher, verificationSpec.continuation, file: file, line: line) } /// Clears all invocations and stubs of mocks. diff --git a/Source/Matching/CallMatcher.swift b/Source/Matching/CallMatcher.swift index e0a6703f..a4f18fa2 100644 --- a/Source/Matching/CallMatcher.swift +++ b/Source/Matching/CallMatcher.swift @@ -11,33 +11,44 @@ public struct CallMatcher { public let name: String private let matchesFunction: ([StubCall]) -> Bool + private let canRecoverFromFailureFunction: ([StubCall]) -> Bool - public init(name: String, matchesFunction: @escaping ([StubCall]) -> Bool) { + public init(name: String, matchesFunction: @escaping ([StubCall]) -> Bool, canRecoverFromFailureFunction: @escaping ([StubCall]) -> Bool) { self.name = name self.matchesFunction = matchesFunction + self.canRecoverFromFailureFunction = canRecoverFromFailureFunction } - - public init(name: String, numberOfExpectedCalls: Int, compareCallsFunction: @escaping (_ expected: Int, _ actual: Int) -> Bool) { - self.init(name: name) { - return compareCallsFunction(numberOfExpectedCalls, $0.count) + + public init( + name: String, + numberOfExpectedCalls: Int, + compareCallsFunction: @escaping (_ expected: Int, _ actual: Int) -> Bool, + canRecoverFromFailureFunction: @escaping (_ expected: Int, _ actual: Int) -> Bool + ) { + self.init(name: name, matchesFunction: { compareCallsFunction(numberOfExpectedCalls, $0.count) }) { + canRecoverFromFailureFunction(numberOfExpectedCalls, $0.count) } } public func matches(_ calls: [StubCall]) -> Bool { return matchesFunction(calls) } + + public func canRecoverFromFailure(_ calls: [StubCall]) -> Bool { + canRecoverFromFailureFunction(calls) + } public func or(_ otherMatcher: CallMatcher) -> CallMatcher { let name = "either \(self.name) or \(otherMatcher.name)" - return CallMatcher(name: name) { - return self.matches($0) || otherMatcher.matches($0) + return CallMatcher(name: name, matchesFunction: { self.matches($0) || otherMatcher.matches($0) }) { + self.canRecoverFromFailure($0) || otherMatcher.canRecoverFromFailureFunction($0) } } public func and(_ otherMatcher: CallMatcher) -> CallMatcher { let name = "both \(self.name) and \(otherMatcher.name)" - return CallMatcher(name: name) { - return self.matches($0) && otherMatcher.matches($0) + return CallMatcher(name: name, matchesFunction: { self.matches($0) && otherMatcher.matches($0) }) { + self.canRecoverFromFailure($0) && otherMatcher.canRecoverFromFailureFunction($0) } } } diff --git a/Source/Matching/CallMatcherFunctions.swift b/Source/Matching/CallMatcherFunctions.swift index 9f9b9844..9e06cab6 100644 --- a/Source/Matching/CallMatcherFunctions.swift +++ b/Source/Matching/CallMatcherFunctions.swift @@ -9,7 +9,7 @@ /// Returns a matcher ensuring a call was made **`count`** times. public func times(_ count: Int) -> CallMatcher { let name = count == 0 ? "never" : "\(count) times" - return CallMatcher(name: name, numberOfExpectedCalls: count, compareCallsFunction: ==) + return CallMatcher(name: name, numberOfExpectedCalls: count, compareCallsFunction: ==, canRecoverFromFailureFunction: >=) } /// Returns a matcher ensuring no call was made. @@ -24,10 +24,10 @@ public func atLeastOnce() -> CallMatcher { /// Returns a matcher ensuring call was made at least `count` times. public func atLeast(_ count: Int) -> CallMatcher { - return CallMatcher(name: "at least \(count) times", numberOfExpectedCalls: count, compareCallsFunction: <=) + return CallMatcher(name: "at least \(count) times", numberOfExpectedCalls: count, compareCallsFunction: <=, canRecoverFromFailureFunction: <=) } /// Returns a matcher ensuring call was made at most `count` times. public func atMost(_ count: Int) -> CallMatcher { - return CallMatcher(name: "at most \(count) times",numberOfExpectedCalls: count, compareCallsFunction: >=) + return CallMatcher(name: "at most \(count) times",numberOfExpectedCalls: count, compareCallsFunction: >=, canRecoverFromFailureFunction: >=) } diff --git a/Source/Mock/Mock.swift b/Source/Mock/Mock.swift index cc6e873c..26ffc015 100644 --- a/Source/Mock/Mock.swift +++ b/Source/Mock/Mock.swift @@ -23,6 +23,8 @@ public protocol Mock: HasMockManager, HasSuperclass { func getVerificationProxy(_ callMatcher: CallMatcher, sourceLocation: SourceLocation) -> Verification + func getVerificationProxy(_ callMatcher: CallMatcher, _ continuation: Continuation, sourceLocation: SourceLocation) -> Verification + func enableDefaultImplementation(_ stub: MocksType) } @@ -35,6 +37,10 @@ public extension Mock { return Verification(manager: cuckoo_manager, callMatcher: callMatcher, sourceLocation: sourceLocation) } + func getVerificationProxy(_ callMatcher: CallMatcher, _ continuation: Continuation, sourceLocation: SourceLocation) -> Verification { + return Verification(manager: cuckoo_manager, callMatcher: callMatcher, continuation: continuation, sourceLocation: sourceLocation) + } + func withEnabledDefaultImplementation(_ stub: MocksType) -> Self { enableDefaultImplementation(stub) return self diff --git a/Source/Mock/VerificationProxy.swift b/Source/Mock/VerificationProxy.swift index 01587e95..dbf3cd63 100644 --- a/Source/Mock/VerificationProxy.swift +++ b/Source/Mock/VerificationProxy.swift @@ -8,4 +8,6 @@ public protocol VerificationProxy { init(manager: MockManager, callMatcher: CallMatcher, sourceLocation: SourceLocation) + + init(manager: MockManager, callMatcher: CallMatcher, continuation: Continuation, sourceLocation: SourceLocation) } diff --git a/Source/MockManager.swift b/Source/MockManager.swift index 5016c3b4..54ad53ee 100644 --- a/Source/MockManager.swift +++ b/Source/MockManager.swift @@ -208,18 +208,31 @@ public class MockManager { return stub } - public func verify(_ method: String, callMatcher: CallMatcher, parameterMatchers: [ParameterMatcher], sourceLocation: SourceLocation) -> __DoNotUse { + public func verify(_ method: String, callMatcher: CallMatcher, parameterMatchers: [ParameterMatcher], continuation: Continuation, sourceLocation: SourceLocation) -> __DoNotUse { var calls: [StubCall] = [] var indexesToRemove: [Int] = [] - for (i, stubCall) in stubCalls.enumerated() { - if let stubCall = stubCall as? ConcreteStubCall , (parameterMatchers.reduce(stubCall.method == method) { $0 && $1.matches(stubCall.parameters) }) { - calls.append(stubCall) - indexesToRemove.append(i) + var matches = false + while continuation.check() { + calls = [] + indexesToRemove = [] + for (i, stubCall) in stubCalls.enumerated() { + if let stubCall = stubCall as? ConcreteStubCall , (parameterMatchers.reduce(stubCall.method == method) { $0 && $1.matches(stubCall.parameters) }) { + calls.append(stubCall) + indexesToRemove.append(i) + } } + matches = callMatcher.matches(calls) + if !matches && !callMatcher.canRecoverFromFailure(calls) { + break + } + if matches && continuation.exitOnSuccess { + break + } + continuation.wait() } unverifiedStubCallsIndexes = unverifiedStubCallsIndexes.filter { !indexesToRemove.contains($0) } - if callMatcher.matches(calls) == false { + if matches == false { let message = "Wanted \(callMatcher.name) but \(calls.count == 0 ? "not invoked" : "invoked \(calls.count) times")." MockManager.fail((message, sourceLocation)) } diff --git a/Source/Verification/VerificationSpec.swift b/Source/Verification/VerificationSpec.swift new file mode 100644 index 00000000..8be93b16 --- /dev/null +++ b/Source/Verification/VerificationSpec.swift @@ -0,0 +1,13 @@ +// +// VerificationSpec.swift +// Cuckoo +// +// Created by Shoto Kobayashi on 03/09/2022. +// + +import Foundation + +public struct VerificationSpec { + let callMatcher: CallMatcher + let continuation: Continuation +} diff --git a/Source/Verification/VerifyProperty/VerifyProperty.swift b/Source/Verification/VerifyProperty/VerifyProperty.swift index 428f672b..460a278a 100644 --- a/Source/Verification/VerifyProperty/VerifyProperty.swift +++ b/Source/Verification/VerifyProperty/VerifyProperty.swift @@ -10,22 +10,24 @@ public struct VerifyProperty { private let manager: MockManager private let name: String private let callMatcher: CallMatcher + private let continuation: Continuation private let sourceLocation: SourceLocation @discardableResult public func get() -> __DoNotUse { - return manager.verify(getterName(name), callMatcher: callMatcher, parameterMatchers: [] as [ParameterMatcher], sourceLocation: sourceLocation) + return manager.verify(getterName(name), callMatcher: callMatcher, parameterMatchers: [] as [ParameterMatcher], continuation: continuation, sourceLocation: sourceLocation) } @discardableResult public func set(_ matcher: M) -> __DoNotUse where M.MatchedType == T { - return manager.verify(setterName(name), callMatcher: callMatcher, parameterMatchers: [matcher.matcher], sourceLocation: sourceLocation) + return manager.verify(setterName(name), callMatcher: callMatcher, parameterMatchers: [matcher.matcher], continuation: continuation, sourceLocation: sourceLocation) } - public init(manager: MockManager, name: String, callMatcher: CallMatcher, sourceLocation: SourceLocation) { + public init(manager: MockManager, name: String, callMatcher: CallMatcher, continuation: Continuation, sourceLocation: SourceLocation) { self.manager = manager self.name = name self.callMatcher = callMatcher + self.continuation = continuation self.sourceLocation = sourceLocation } } @@ -34,22 +36,24 @@ public struct VerifyOptionalProperty { private let manager: MockManager private let name: String private let callMatcher: CallMatcher + private let continuation: Continuation private let sourceLocation: SourceLocation @discardableResult public func get() -> __DoNotUse { - return manager.verify(getterName(name), callMatcher: callMatcher, parameterMatchers: [] as [ParameterMatcher], sourceLocation: sourceLocation) + return manager.verify(getterName(name), callMatcher: callMatcher, parameterMatchers: [] as [ParameterMatcher], continuation: continuation, sourceLocation: sourceLocation) } @discardableResult public func set(_ matcher: M) -> __DoNotUse where M.OptionalMatchedType == T { - return manager.verify(setterName(name), callMatcher: callMatcher, parameterMatchers: [matcher.optionalMatcher], sourceLocation: sourceLocation) + return manager.verify(setterName(name), callMatcher: callMatcher, parameterMatchers: [matcher.optionalMatcher], continuation: continuation, sourceLocation: sourceLocation) } - public init(manager: MockManager, name: String, callMatcher: CallMatcher, sourceLocation: SourceLocation) { + public init(manager: MockManager, name: String, callMatcher: CallMatcher, continuation: Continuation, sourceLocation: SourceLocation) { self.manager = manager self.name = name self.callMatcher = callMatcher + self.continuation = continuation self.sourceLocation = sourceLocation } } diff --git a/Source/Verification/VerifyProperty/VerifyReadOnlyProperty.swift b/Source/Verification/VerifyProperty/VerifyReadOnlyProperty.swift index 0aa7d43e..e3bbae92 100644 --- a/Source/Verification/VerifyProperty/VerifyReadOnlyProperty.swift +++ b/Source/Verification/VerifyProperty/VerifyReadOnlyProperty.swift @@ -10,17 +10,19 @@ public struct VerifyReadOnlyProperty { private let manager: MockManager private let name: String private let callMatcher: CallMatcher + private let continuation: Continuation private let sourceLocation: SourceLocation @discardableResult public func get() -> __DoNotUse { - return manager.verify(getterName(name), callMatcher: callMatcher, parameterMatchers: [] as [ParameterMatcher], sourceLocation: sourceLocation) + return manager.verify(getterName(name), callMatcher: callMatcher, parameterMatchers: [] as [ParameterMatcher], continuation: continuation, sourceLocation: sourceLocation) } - public init(manager: MockManager, name: String, callMatcher: CallMatcher, sourceLocation: SourceLocation) { + public init(manager: MockManager, name: String, callMatcher: CallMatcher, continuation: Continuation, sourceLocation: SourceLocation) { self.manager = manager self.name = name self.callMatcher = callMatcher + self.continuation = continuation self.sourceLocation = sourceLocation } } diff --git a/Tests/Swift/Matching/CallMatcherTest.swift b/Tests/Swift/Matching/CallMatcherTest.swift index 168b3567..bc0efc47 100644 --- a/Tests/Swift/Matching/CallMatcherTest.swift +++ b/Tests/Swift/Matching/CallMatcherTest.swift @@ -16,7 +16,7 @@ class CallMatcherTest: XCTestCase { } func testMatches() { - let matcher = CallMatcher(name: "") { ($0.first?.method ?? "") == "A"} + let matcher = CallMatcher(name: "", matchesFunction: { ($0.first?.method ?? "") == "A"}) { $0.first == nil } let nonMatchingCalls = [ConcreteStubCall(method: "B", parameters: Void()) as StubCall] XCTAssertTrue(matcher.matches(call))