mirror of https://github.com/oxen-io/session-ios
				
				
				
			
			You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
	
	
		
			254 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			Swift
		
	
		
		
			
		
	
	
			254 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			Swift
		
	
| 
											4 years ago
										 | // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. | ||
|  | 
 | ||
|  | import Foundation | ||
|  | import Nimble | ||
| 
											4 years ago
										 | import SessionUtilitiesKit | ||
| 
											4 years ago
										 | 
 | ||
|  | public enum CallAmount { | ||
|  |     case atLeast(times: Int) | ||
|  |     case exactly(times: Int) | ||
|  |     case noMoreThan(times: Int) | ||
|  | } | ||
|  | 
 | ||
|  | fileprivate func timeStr(_ value: Int) -> String { | ||
|  |     return "\(value) time\(value == 1 ? "" : "s")" | ||
|  | } | ||
|  | 
 | ||
|  | /// Validates whether the function called in `functionBlock` has been called according to the parameter constraints | ||
|  | /// | ||
|  | /// - Parameters: | ||
|  | ///  - amount: An enum constraining the number of times the function can be called (Default is `.atLeast(times: 1)` | ||
|  | /// | ||
|  | ///  - matchingParameters: A boolean indicating whether the parameters for the function call need to match exactly | ||
|  | /// | ||
|  | ///  - exclusive: A boolean indicating whether no other functions should be called | ||
|  | /// | ||
|  | ///  - functionBlock: A closure in which the function to be validated should be called | ||
|  | public func call<M, T, R>( | ||
|  |     _ amount: CallAmount = .atLeast(times: 1), | ||
|  |     matchingParameters: Bool = false, | ||
|  |     exclusive: Bool = false, | ||
| 
											4 years ago
										 |     functionBlock: @escaping (inout T) throws -> R | ||
| 
											4 years ago
										 | ) -> Predicate<M> where M: Mock<T> { | ||
|  |     return Predicate.define { actualExpression in | ||
|  |         let callInfo: CallInfo = generateCallInfo(actualExpression, functionBlock) | ||
|  |         let matchingParameterRecords: [String] = callInfo.desiredFunctionCalls | ||
|  |             .filter { !matchingParameters || callInfo.hasMatchingParameters($0) } | ||
|  |         let exclusiveCallsValid: Bool = (!exclusive || callInfo.allFunctionsCalled.count <= 1)  // '<=' to support '0' case | ||
|  |         let (numParamMatchingCallsValid, timesError): (Bool, String?) = { | ||
|  |             switch amount { | ||
|  |                 case .atLeast(let times): | ||
|  |                     return ( | ||
|  |                         (matchingParameterRecords.count >= times), | ||
|  |                         (times <= 1 ? nil : "at least \(timeStr(times))") | ||
|  |                     ) | ||
|  |                  | ||
|  |                 case .exactly(let times): | ||
|  |                     return ( | ||
|  |                         (matchingParameterRecords.count == times), | ||
|  |                         "exactly \(timeStr(times))" | ||
|  |                     ) | ||
|  |                      | ||
|  |                 case .noMoreThan(let times): | ||
|  |                     return ( | ||
|  |                         (matchingParameterRecords.count <= times), | ||
|  |                         (times <= 0 ? nil : "no more than \(timeStr(times))") | ||
|  |                     ) | ||
|  |             } | ||
|  |         }() | ||
|  |          | ||
|  |         let result = ( | ||
|  |             numParamMatchingCallsValid && | ||
|  |             exclusiveCallsValid | ||
|  |         ) | ||
|  |         let matchingParametersError: String? = (matchingParameters ? | ||
|  |             "matching the parameters\(callInfo.desiredParameters.map { ": \($0)" } ?? "")" : | ||
|  |             nil | ||
|  |         ) | ||
|  |         let distinctParameterCombinations: Set<String> = Set(callInfo.desiredFunctionCalls) | ||
|  |         let actualMessage: String | ||
|  |          | ||
|  |         if callInfo.caughtException != nil { | ||
|  |             actualMessage = "a thrown assertion (might not have been called or has no mocked return value)" | ||
|  |         } | ||
|  |         else if callInfo.function == nil { | ||
|  |             actualMessage = "no call details" | ||
|  |         } | ||
|  |         else if callInfo.desiredFunctionCalls.isEmpty { | ||
|  |             actualMessage = "no calls" | ||
|  |         } | ||
|  |         else if !exclusiveCallsValid { | ||
|  |             let otherFunctionsCalled: [String] = callInfo.allFunctionsCalled.filter { $0 != callInfo.functionName } | ||
|  |              | ||
|  |             actualMessage = "calls to other functions: [\(otherFunctionsCalled.joined(separator: ", "))]" | ||
|  |         } | ||
|  |         else { | ||
|  |             let onlyMadeMatchingCalls: Bool = (matchingParameterRecords.count == callInfo.desiredFunctionCalls.count) | ||
|  |              | ||
|  |             switch (numParamMatchingCallsValid, onlyMadeMatchingCalls, distinctParameterCombinations.count) { | ||
|  |                 case (false, false, 1): | ||
|  |                     // No calls with the matching parameter requirements but only one parameter combination | ||
|  |                     // so include the param info | ||
|  |                     actualMessage = "called \(timeStr(callInfo.desiredFunctionCalls.count)) with different parameters: \(callInfo.desiredFunctionCalls[0])" | ||
|  |                      | ||
|  |                 case (false, true, _): | ||
|  |                     actualMessage = "called \(timeStr(callInfo.desiredFunctionCalls.count))" | ||
|  |                      | ||
|  |                 case (false, false, _): | ||
| 
											4 years ago
										 |                     let distinctSetterCombinations: Set<String> = distinctParameterCombinations.filter { $0 != "[]" } | ||
|  |                      | ||
|  |                     // A getter/setter combo will have function calls split between no params and the set value | ||
|  |                     // if the setter didn't match then we still want to show the incorrect parameters | ||
|  |                     if distinctSetterCombinations.count == 1, let paramCombo: String = distinctSetterCombinations.first { | ||
|  |                         actualMessage = "called with: \(paramCombo)" | ||
|  |                     } | ||
|  |                     else { | ||
|  |                         actualMessage = "called \(timeStr(matchingParameterRecords.count)) with matching parameters, \(timeStr(callInfo.desiredFunctionCalls.count)) total" | ||
|  |                     } | ||
| 
											4 years ago
										 |                  | ||
| 
											4 years ago
										 |                 default: actualMessage = "\(exclusive ? " exclusive " : "")call to '\(callInfo.functionName)'" | ||
| 
											4 years ago
										 |             } | ||
|  |         } | ||
|  |          | ||
|  |         return PredicateResult( | ||
|  |             bool: result, | ||
|  |             message: .expectedCustomValueTo( | ||
|  |                 [ | ||
|  |                     "call '\(callInfo.functionName)'\(exclusive ? " exclusively" : "")", | ||
|  |                     timesError, | ||
|  |                     matchingParametersError | ||
|  |                 ] | ||
|  |                 .compactMap { $0 } | ||
|  |                 .joined(separator: " "), | ||
|  |                 actual: actualMessage | ||
|  |             ) | ||
|  |         ) | ||
|  |     } | ||
|  | } | ||
|  | 
 | ||
|  | // MARK: - Shared Code | ||
|  | 
 | ||
|  | fileprivate struct CallInfo { | ||
|  |     let didError: Bool | ||
|  |     let caughtException: BadInstructionException? | ||
|  |     let function: MockFunction? | ||
|  |     let allFunctionsCalled: [String] | ||
|  |     let desiredFunctionCalls: [String] | ||
|  |      | ||
|  |     var functionName: String { "\((function?.name).map { "\($0)" } ?? "a function")" } | ||
|  |     var desiredParameters: String? { function?.parameterSummary } | ||
|  |      | ||
|  |     static var error: CallInfo { | ||
|  |         CallInfo( | ||
|  |             didError: true, | ||
|  |             caughtException: nil, | ||
|  |             function: nil, | ||
|  |             allFunctionsCalled: [], | ||
|  |             desiredFunctionCalls: [] | ||
|  |         ) | ||
|  |     } | ||
|  |      | ||
|  |     init( | ||
|  |         didError: Bool = false, | ||
|  |         caughtException: BadInstructionException?, | ||
|  |         function: MockFunction?, | ||
|  |         allFunctionsCalled: [String], | ||
|  |         desiredFunctionCalls: [String] | ||
|  |     ) { | ||
|  |         self.didError = didError | ||
|  |         self.caughtException = caughtException | ||
|  |         self.function = function | ||
|  |         self.allFunctionsCalled = allFunctionsCalled | ||
|  |         self.desiredFunctionCalls = desiredFunctionCalls | ||
|  |     } | ||
|  |      | ||
|  |     func hasMatchingParameters(_ parameters: String) -> Bool { | ||
|  |         return (parameters == (function?.parameterSummary ?? "FALLBACK_NOT_FOUND")) | ||
|  |     } | ||
|  | } | ||
|  | 
 | ||
| 
											4 years ago
										 | fileprivate func generateCallInfo<M, T, R>(_ actualExpression: Expression<M>, _ functionBlock: @escaping (inout T) throws -> R) -> CallInfo where M: Mock<T> { | ||
| 
											4 years ago
										 |     var maybeFunction: MockFunction? | ||
|  |     var allFunctionsCalled: [String] = [] | ||
|  |     var desiredFunctionCalls: [String] = [] | ||
|  |     let builderCreator: ((M) -> MockFunctionBuilder<T, R>) = { validInstance in | ||
|  |         let builder: MockFunctionBuilder<T, R> = MockFunctionBuilder(functionBlock, mockInit: type(of: validInstance).init) | ||
|  |         builder.returnValueGenerator = { name, parameterSummary in | ||
|  |             validInstance.functionConsumer | ||
|  |                 .firstFunction(for: name, matchingParameterSummaryIfPossible: parameterSummary)? | ||
|  |                 .returnValue as? R | ||
|  |         } | ||
|  |          | ||
|  |         return builder | ||
|  |     } | ||
|  |      | ||
|  |     #if (arch(x86_64) || arch(arm64)) && (canImport(Darwin) || canImport(Glibc)) | ||
|  |     var didError: Bool = false | ||
|  |     let caughtException: BadInstructionException? = catchBadInstruction { | ||
|  |         do { | ||
|  |             guard let validInstance: M = try actualExpression.evaluate() else { | ||
|  |                 didError = true | ||
|  |                 return | ||
|  |             } | ||
|  |              | ||
| 
											4 years ago
										 |             allFunctionsCalled = Array(validInstance.functionConsumer.calls.wrappedValue.keys) | ||
| 
											4 years ago
										 |              | ||
| 
											4 years ago
										 |             // Only check for the specific function calls if there was at least a single | ||
|  |             // call (if there weren't any this will likely throw errors when attempting | ||
|  |             // to build) | ||
|  |             if !allFunctionsCalled.isEmpty { | ||
|  |                 let builder: MockFunctionBuilder<T, R> = builderCreator(validInstance) | ||
|  |                 validInstance.functionConsumer.trackCalls = false | ||
|  |                 maybeFunction = try? builder.build() | ||
|  |                 desiredFunctionCalls = validInstance.functionConsumer.calls | ||
|  |                     .wrappedValue[maybeFunction?.name ?? ""] | ||
|  |                     .defaulting(to: []) | ||
|  |                 validInstance.functionConsumer.trackCalls = true | ||
|  |             } | ||
|  |             else { | ||
|  |                 desiredFunctionCalls = [] | ||
|  |             } | ||
| 
											4 years ago
										 |         } | ||
|  |         catch { | ||
|  |             didError = true | ||
|  |         } | ||
|  |     } | ||
|  |      | ||
|  |     // Make sure to switch this back on in case an assertion was thrown (which would meant this | ||
|  |     // wouldn't have been reset) | ||
|  |     (try? actualExpression.evaluate())?.functionConsumer.trackCalls = true | ||
|  |      | ||
|  |     guard !didError else { return CallInfo.error } | ||
|  |     #else | ||
|  |     let caughtException: BadInstructionException? = nil | ||
|  |      | ||
|  |     // Just hope for the best and if there is a force-cast there's not much we can do | ||
|  |     guard let validInstance: M = try? actualExpression.evaluate() else { return CallInfo.error } | ||
|  |      | ||
| 
											4 years ago
										 |     allFunctionsCalled = Array(validInstance.functionConsumer.calls.wrappedValue.keys) | ||
| 
											4 years ago
										 |      | ||
| 
											4 years ago
										 |     // Only check for the specific function calls if there was at least a single | ||
|  |     // call (if there weren't any this will likely throw errors when attempting | ||
|  |     // to build) | ||
|  |     if !allFunctionsCalled.isEmpty { | ||
|  |         let builder: MockExpectationBuilder<T, R> = builderCreator(validInstance) | ||
|  |         validInstance.functionConsumer.trackCalls = false | ||
|  |         maybeFunction = try? builder.build() | ||
|  |         desiredFunctionCalls = validInstance.functionConsumer.calls | ||
|  |             .wrappedValue[maybeFunction?.name ?? ""] | ||
|  |             .defaulting(to: []) | ||
|  |         validInstance.functionConsumer.trackCalls = true | ||
|  |     } | ||
|  |     else { | ||
|  |         desiredFunctionCalls = [] | ||
|  |     } | ||
| 
											4 years ago
										 |     #endif | ||
|  |      | ||
|  |     return CallInfo( | ||
|  |         caughtException: caughtException, | ||
|  |         function: maybeFunction, | ||
|  |         allFunctionsCalled: allFunctionsCalled, | ||
|  |         desiredFunctionCalls: desiredFunctionCalls | ||
|  |     ) | ||
|  | } |