MobiusTest/Source/NextMatchers.swift (149 lines of code) (raw):

// Copyright Spotify AB. // SPDX-License-Identifier: Apache-2.0 import MobiusCore import XCTest public typealias NextPredicate<Model, Effect> = Predicate<Next<Model, Effect>> /// Convenience function to produce `UpdateSpec` `Assert` /// /// - Parameters: /// - predicate: a list of predicates to test /// - failFunction: a function which is called when the predicate fails. Defaults to XCTFail /// - Returns: An `UpdateSpec` `Assert` that uses the assert to verify the result passed in to the `Assert` public func assertThatNext<Model, Event, Effect>( _ predicates: NextPredicate<Model, Effect>..., failFunction: @escaping AssertionFailure = XCTFail ) -> UpdateSpec<Model, Event, Effect>.Assert { return { (result: UpdateSpec<Model, Event, Effect>.Result) in predicates.forEach({ predicate in let assertionResult = predicate(result.lastNext) if case .failure(let message, let file, let line) = assertionResult { failFunction(message, file, line) } }) } } /// - Returns: a `Predicate` that matches `Next` instances with no model and no effects. public func hasNothing<Model, Effect>(file: StaticString = #file, line: UInt = #line) -> NextPredicate<Model, Effect> { return { (next: Next<Model, Effect>) in let noModelResult = hasNoModel(file: file, line: line)(next) if case .success = noModelResult { return hasNoEffects(file: file, line: line)(next) } return noModelResult } } /// - Returns: a `Predicate` that matches `Next` instances without a model. public func hasNoModel<Model, Effect>(file: StaticString = #file, line: UInt = #line) -> NextPredicate<Model, Effect> { return { (next: Next<Model, Effect>) in let model = next.model if model != nil { return .failure( message: "Expected final Next to have no model. Got: <\(String(describing: model!))>", file: file, line: line ) } return .success } } /// - Returns: a `Predicate` that matches `Next` instances with a model. public func hasModel<Model, Effect>(file: StaticString = #file, line: UInt = #line) -> NextPredicate<Model, Effect> { return { (next: Next<Model, Effect>) in let model = next.model if model == nil { return .failure( message: "Expected final Next to have a model. Got: <nil>", file: file, line: line ) } return .success } } /// - Parameter expected: the expected model /// - Returns: a `Predicate` that matches `Next` instances with a model that is equal to the supplied one. public func hasModel<Model: Equatable, Effect>( _ expected: Model, file: StaticString = #file, line: UInt = #line ) -> NextPredicate<Model, Effect> { return { (next: Next<Model, Effect>) in let actual = next.model if actual != expected { return .failure( message: "Different final model than expected (−), got (+): \n" + "\(dumpDiff(expected, actual))", file: file, line: line ) } return .success } } /// - Returns: a `Predicate` that matches `Next` instances with no effects. public func hasNoEffects<Model, Effect>( file: StaticString = #file, line: UInt = #line ) -> NextPredicate<Model, Effect> { return { (next: Next<Model, Effect>) in if !next.effects.isEmpty { return .failure( message: "Expected no effects. Got: <\(next.effects)>", file: file, line: line ) } return .success } } /// Constructs a matcher that matches if all the supplied effects are present in the supplied `Next`, in any order. /// The `Next` may have more effects than the ones included. /// /// - Parameter expected: the effects to match (possibly empty) /// - Returns: a `Predicate` that matches `Next` instances that include all the supplied effects public func hasEffects<Model, Effect: Equatable>( _ expected: [Effect], file: StaticString = #file, line: UInt = #line ) -> NextPredicate<Model, Effect> { return { (next: Next<Model, Effect>) in let actual = next.effects let unmatchedExpected = expected.filter { !actual.contains($0) } guard !unmatchedExpected.isEmpty else { return .success } // Find the effects that were produced but not expected - this is permitted, but there might be a close match // there let unmatchedActual = actual.filter { !expected.contains($0) } return .failure( message: "Missing \(countedEffects(unmatchedExpected, label: "expected")) (−), got (+)" + " (with \(countedEffects(unmatchedActual, label: "actual")) unmatched):\n" + dumpDiffFuzzy(expected: unmatchedExpected, actual: unmatchedActual, withUnmatchedActual: false), file: file, line: line ) } } /// Constructs a matcher that matches if only the supplied effects are present in the supplied `Next`, in any order. /// /// - Parameter expected: the effects to match (possibly empty) /// - Returns: a `Predicate` that matches `Next` instances that include all the supplied effects public func hasOnlyEffects<Model, Effect: Equatable>( _ expected: [Effect], file: StaticString = #file, line: UInt = #line ) -> NextPredicate<Model, Effect> { return { (next: Next<Model, Effect>) in let actual = next.effects let unmatchedExpected = expected.filter { !actual.contains($0) } let unmatchedActual = actual.filter { !expected.contains($0) } var errorString = [ !unmatchedExpected.isEmpty ? "missing \(countedEffects(unmatchedExpected, label: "expected")) (−)" : nil, !unmatchedActual.isEmpty ? "got \(countedEffects(unmatchedActual, label: "actual unmatched")) (+)" : nil, ].compactMap { $0 }.joined(separator: ", ") errorString = errorString.prefix(1).capitalized + errorString.dropFirst() if !errorString.isEmpty { return .failure( message: "\(errorString):\n" + dumpDiffFuzzy(expected: unmatchedExpected, actual: unmatchedActual, withUnmatchedActual: true), file: file, line: line ) } return .success } } /// Constructs a matcher that matches if the supplied effects are equal to the supplied `Next`. /// /// - Parameter expected: the effects to match (possibly empty) /// - Returns: a `Predicate` that matches `Next` instances that include all the supplied effects public func hasExactlyEffects<Model, Effect: Equatable>( _ expected: [Effect], file: StaticString = #file, line: UInt = #line ) -> NextPredicate<Model, Effect> { return { (next: Next<Model, Effect>) in if next.effects != expected { return .failure( message: "Different effects than expected (−), got (+): \n" + "\(dumpDiff(expected, next.effects))", file: file, line: line ) } return .success } } private func countedEffects<T>(_ effects: [T], label: String) -> String { let count = effects.count return count == 1 ? "1 \(label) effect" : "\(count) \(label) effects" }