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.
266 lines
11 KiB
Swift
266 lines
11 KiB
Swift
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
|
|
|
import Foundation
|
|
import Nimble
|
|
import CwlPreconditionTesting
|
|
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<M, T, R>(
|
|
_ amount: CallAmount = .atLeast(times: 1),
|
|
matchingParameters: Bool = false,
|
|
exclusive: Bool = false,
|
|
functionBlock: @escaping (inout T) throws -> R
|
|
) -> Nimble.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 (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<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"
|
|
}
|
|
|
|
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<M, T, R>(_ actualExpression: Expression<M>, _ functionBlock: @escaping (inout T) throws -> R) -> CallInfo where M: Mock<T> {
|
|
var maybeFunction: MockFunction?
|
|
var allFunctionsCalled: [FunctionConsumer.Key] = []
|
|
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, 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<T, R> = 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<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 = []
|
|
}
|
|
#endif
|
|
|
|
return CallInfo(
|
|
caughtException: caughtException,
|
|
function: maybeFunction,
|
|
allFunctionsCalled: allFunctionsCalled,
|
|
desiredFunctionCalls: desiredFunctionCalls
|
|
)
|
|
}
|