// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation import Nimble import SessionUtilitiesKit 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( _ amount: CallAmount = .atLeast(times: 1), matchingParameters: Bool = false, exclusive: Bool = false, functionBlock: @escaping (inout T) throws -> R ) -> Nimble.Predicate where M: Mock { 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 = Set(callInfo.desiredFunctionCalls) let actualMessage: String if callInfo.caughtException != nil { actualMessage = "a thrown assertion (invalid mock param, not called or 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 .map { "\($0.name) (params: \($0.paramCount))" } .filter { $0 != "\(callInfo.functionName) (params: \(callInfo.parameterCount))" } 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, _): let distinctSetterCombinations: Set = 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" } default: actualMessage = "\(exclusive ? " exclusive " : "")call to '\(callInfo.functionName)'" } } 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: [FunctionConsumer.Key] let desiredFunctionCalls: [String] var functionName: String { "\((function?.name).map { "\($0)" } ?? "a function")" } var parameterCount: Int { (function?.parameterCount ?? 0) } 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: [FunctionConsumer.Key], 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")) } } fileprivate func generateCallInfo(_ actualExpression: Expression, _ functionBlock: @escaping (inout T) throws -> R) -> CallInfo where M: Mock { var maybeFunction: MockFunction? var allFunctionsCalled: [FunctionConsumer.Key] = [] var desiredFunctionCalls: [String] = [] let builderCreator: ((M) -> MockFunctionBuilder) = { validInstance in let builder: MockFunctionBuilder = MockFunctionBuilder(functionBlock, mockInit: type(of: validInstance).init) builder.returnValueGenerator = { name, parameterCount, parameterSummary in validInstance.functionConsumer .firstFunction( for: FunctionConsumer.Key(name: name, paramCount: parameterCount), 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 } allFunctionsCalled = Array(validInstance.functionConsumer.calls.wrappedValue.keys) // 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 = builderCreator(validInstance) validInstance.functionConsumer.trackCalls = false maybeFunction = try? builder.build() let key: FunctionConsumer.Key = FunctionConsumer.Key( name: (maybeFunction?.name ?? ""), paramCount: (maybeFunction?.parameterCount ?? 0) ) desiredFunctionCalls = validInstance.functionConsumer.calls .wrappedValue[key] .defaulting(to: []) validInstance.functionConsumer.trackCalls = true } else { desiredFunctionCalls = [] } } 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 } allFunctionsCalled = Array(validInstance.functionConsumer.calls.wrappedValue.keys) // 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 = 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 = [] } #endif return CallInfo( caughtException: caughtException, function: maybeFunction, allFunctionsCalled: allFunctionsCalled, desiredFunctionCalls: desiredFunctionCalls ) }