830 lines
30 KiB
Swift
830 lines
30 KiB
Swift
// Inspired by https://github.com/groue/CombineExpectations
|
|
import XCTest
|
|
@testable import GRDB
|
|
|
|
// MARK: - ValueObservationRecorder
|
|
|
|
public class ValueObservationRecorder<Value> {
|
|
private struct RecorderExpectation {
|
|
var expectation: XCTestExpectation
|
|
var remainingCount: Int? // nil for error expectation
|
|
var isIncluded: ((Value) -> Bool)? // nil for error expectation
|
|
}
|
|
|
|
/// The recorder state
|
|
private struct State {
|
|
var values: [Value]
|
|
var error: Error?
|
|
var recorderExpectation: RecorderExpectation?
|
|
var cancellable: AnyDatabaseCancellable?
|
|
}
|
|
|
|
private let lock = NSLock()
|
|
private var state = State(values: [], recorderExpectation: nil, cancellable: nil)
|
|
private var consumedCount = 0
|
|
|
|
/// Internal for testability. Use ValueObservation.record(in:) instead.
|
|
init() { }
|
|
|
|
private func synchronized<T>(_ execute: () throws -> T) rethrows -> T {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
return try execute()
|
|
}
|
|
|
|
// MARK: ValueObservation API
|
|
|
|
// Internal for testability.
|
|
func onChange(_ value: Value) {
|
|
return synchronized {
|
|
if state.error != nil {
|
|
// This is possible with ValueObservation, but not supported by ValueObservationRecorder
|
|
XCTFail("ValueObservationRecorder got unexpected value after error: \(String(reflecting: value))")
|
|
}
|
|
|
|
state.values.append(value)
|
|
|
|
if let exp = state.recorderExpectation,
|
|
let remainingCount = exp.remainingCount,
|
|
let isIncluded = exp.isIncluded
|
|
{
|
|
assert(remainingCount > 0)
|
|
if isIncluded(value) {
|
|
exp.expectation.fulfill()
|
|
if remainingCount > 1 {
|
|
state.recorderExpectation = RecorderExpectation(
|
|
expectation: exp.expectation,
|
|
remainingCount: remainingCount - 1,
|
|
isIncluded: isIncluded)
|
|
} else {
|
|
state.recorderExpectation = nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Internal for testability.
|
|
func onError(_ error: Error) {
|
|
return synchronized {
|
|
if state.error != nil {
|
|
// This is possible with ValueObservation, but not supported by ValueObservationRecorder
|
|
XCTFail("f got unexpected error after error: \(String(describing: error))")
|
|
}
|
|
|
|
if let exp = state.recorderExpectation {
|
|
exp.expectation.fulfill(count: exp.remainingCount ?? 1)
|
|
state.recorderExpectation = nil
|
|
}
|
|
state.error = error
|
|
}
|
|
}
|
|
|
|
// MARK: ValueObservationExpectation API
|
|
|
|
func fulfillOnValue(_ expectation: XCTestExpectation, includingConsumed: Bool, isIncluded: @escaping (Value) -> Bool) {
|
|
synchronized {
|
|
preconditionCanFulfillExpectation()
|
|
|
|
let expectedFulfillmentCount = expectation.expectedFulfillmentCount
|
|
|
|
if state.error != nil {
|
|
expectation.fulfill(count: expectedFulfillmentCount)
|
|
return
|
|
}
|
|
|
|
let values = state.values.filter(isIncluded)
|
|
let consumedValues = state.values[0..<consumedCount].filter(isIncluded)
|
|
let maxFulfillmentCount = includingConsumed
|
|
? values.count
|
|
: values.count - consumedValues.count
|
|
let fulfillmentCount = min(expectedFulfillmentCount, maxFulfillmentCount)
|
|
expectation.fulfill(count: fulfillmentCount)
|
|
|
|
let remainingCount = expectedFulfillmentCount - fulfillmentCount
|
|
if remainingCount > 0 {
|
|
state.recorderExpectation = RecorderExpectation(
|
|
expectation: expectation,
|
|
remainingCount: remainingCount,
|
|
isIncluded: isIncluded)
|
|
} else {
|
|
state.recorderExpectation = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
func fulfillOnError(_ expectation: XCTestExpectation) {
|
|
synchronized {
|
|
preconditionCanFulfillExpectation()
|
|
|
|
if state.error != nil {
|
|
expectation.fulfill()
|
|
return
|
|
}
|
|
|
|
state.recorderExpectation = RecorderExpectation(
|
|
expectation: expectation,
|
|
remainingCount: nil,
|
|
isIncluded: nil)
|
|
}
|
|
}
|
|
|
|
/// Returns a value based on the recorded state.
|
|
///
|
|
/// - parameter value: A function which returns the value, given the
|
|
/// recorded state.
|
|
/// - parameter values: All recorded values.
|
|
/// - parameter remainingValues: The values that were not consumed yet.
|
|
/// - parameter consume: A function which consumes values.
|
|
/// - parameter count: The number of consumed values.
|
|
/// - returns: The value
|
|
func value<T>(_ value: (
|
|
_ values: [Value],
|
|
_ error: Error?,
|
|
_ remainingValues: ArraySlice<Value>,
|
|
_ consume: (_ count: Int) -> ()) throws -> T)
|
|
rethrows -> T
|
|
{
|
|
try synchronized {
|
|
let values = state.values
|
|
let remainingValues = values[consumedCount...]
|
|
return try value(values, state.error, remainingValues, { count in
|
|
precondition(count >= 0)
|
|
precondition(count <= remainingValues.count)
|
|
consumedCount += count
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Checks that recorder can fulfill an expectation.
|
|
///
|
|
/// The reason this method exists is that a recorder can fulfill a single
|
|
/// expectation at a given time. It is a programmer error to wait for two
|
|
/// expectations concurrently.
|
|
///
|
|
/// This method MUST be called within a synchronized block.
|
|
private func preconditionCanFulfillExpectation() {
|
|
if let exp = state.recorderExpectation {
|
|
// We are already waiting for an expectation! Is it a programmer
|
|
// error? Recorder drops references to non-inverted expectations
|
|
// when they are fulfilled. But inverted expectations are not
|
|
// fulfilled, and thus not dropped. We can't quite know if an
|
|
// inverted expectations has expired yet, so just let it go.
|
|
precondition(exp.expectation.isInverted, "Already waiting for an expectation")
|
|
}
|
|
}
|
|
|
|
fileprivate func receive(_ cancellable: AnyDatabaseCancellable) {
|
|
synchronized {
|
|
if state.cancellable != nil {
|
|
XCTFail("ValueObservationRecorder is already observing")
|
|
}
|
|
state.cancellable = cancellable
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - ValueObservationRecorder + Expectations
|
|
|
|
extension ValueObservationRecorder {
|
|
public func failure() -> ValueObservationExpectations.Failure<Value> {
|
|
ValueObservationExpectations.Failure(recorder: self)
|
|
}
|
|
|
|
public func next() -> ValueObservationExpectations.NextOne<Value> {
|
|
ValueObservationExpectations.NextOne(recorder: self)
|
|
}
|
|
|
|
public func next(_ count: Int) -> ValueObservationExpectations.Next<Value> {
|
|
ValueObservationExpectations.Next(recorder: self, count: count)
|
|
}
|
|
|
|
public func prefix(_ maxLength: Int) -> ValueObservationExpectations.Prefix<Value> {
|
|
ValueObservationExpectations.Prefix(
|
|
recorder: self,
|
|
expectedFulfillmentCount: maxLength,
|
|
isIncluded: { _ in true })
|
|
}
|
|
|
|
public func prefix(until predicate: @escaping (Value) -> Bool) -> ValueObservationExpectations.Prefix<Value> {
|
|
ValueObservationExpectations.Prefix(
|
|
recorder: self,
|
|
expectedFulfillmentCount: 1,
|
|
isIncluded: predicate)
|
|
}
|
|
}
|
|
|
|
// MARK: - ValueObservation + ValueObservationRecorder
|
|
|
|
extension ValueObservation {
|
|
public func record(
|
|
in reader: some DatabaseReader,
|
|
scheduling scheduler: some ValueObservationScheduler = .async(onQueue: .main),
|
|
onError: ((Error) -> Void)? = nil,
|
|
onChange: ((Reducer.Value) -> Void)? = nil)
|
|
-> ValueObservationRecorder<Reducer.Value>
|
|
where Reducer: ValueReducer
|
|
{
|
|
let recorder = ValueObservationRecorder<Reducer.Value>()
|
|
let cancellable = start(
|
|
in: reader,
|
|
scheduling: scheduler,
|
|
onError: { [weak recorder] in
|
|
onError?($0)
|
|
recorder?.onError($0)
|
|
},
|
|
onChange: { [weak recorder] in
|
|
onChange?($0)
|
|
recorder?.onChange($0)
|
|
})
|
|
recorder.receive(cancellable)
|
|
return recorder
|
|
}
|
|
}
|
|
|
|
// MARK: - ValueObservationExpectation
|
|
|
|
public enum ValueRecordingError: Error {
|
|
case notEnoughValues
|
|
case notFailed
|
|
}
|
|
|
|
extension ValueRecordingError: LocalizedError {
|
|
public var errorDescription: String? {
|
|
switch self {
|
|
case .notEnoughValues:
|
|
return "ValueRecordingError.notEnoughValues"
|
|
case .notFailed:
|
|
return "ValueRecordingError.notFailed"
|
|
}
|
|
}
|
|
}
|
|
|
|
public protocol _ValueObservationExpectationBase {
|
|
func _setup(_ expectation: XCTestExpectation)
|
|
}
|
|
|
|
public protocol ValueObservationExpectation: _ValueObservationExpectationBase {
|
|
associatedtype Output
|
|
func get() throws -> Output
|
|
}
|
|
|
|
// MARK: - XCTestCase + ValueObservationExpectation
|
|
|
|
extension XCTestCase {
|
|
public func wait<E: ValueObservationExpectation>(
|
|
for valueObservationExpectation: E,
|
|
timeout: TimeInterval,
|
|
description: String = "")
|
|
throws -> E.Output
|
|
{
|
|
let expectation = self.expectation(description: description)
|
|
valueObservationExpectation._setup(expectation)
|
|
wait(for: [expectation], timeout: timeout)
|
|
return try valueObservationExpectation.get()
|
|
}
|
|
|
|
/// This test checks the fundamental promise of ValueObservation by
|
|
/// comparing recorded values with expected values.
|
|
///
|
|
/// Recorded values match the expected values if and only if:
|
|
///
|
|
/// - The last recorded value is the last expected value
|
|
/// - Recorded values are in the same order as expected values
|
|
///
|
|
/// However, both missing and repeated values are allowed - with the only
|
|
/// exception of the last expected value which can not be missed.
|
|
///
|
|
/// For example, if the expected values are [0, 1], then the following
|
|
/// recorded values match:
|
|
///
|
|
/// - `[0, 1]` (identical values)
|
|
/// - `[1]` (missing value but the last one)
|
|
/// - `[0, 0, 1, 1]` (repeated value)
|
|
///
|
|
/// However the following recorded values don't match, and fail the test:
|
|
///
|
|
/// - `[1, 0]` (wrong order)
|
|
/// - `[0]` (missing last value)
|
|
/// - `[]` (missing last value)
|
|
/// - `[0, 1, 2]` (unexpected value)
|
|
/// - `[1, 0, 1]` (unexpected value)
|
|
func assertValueObservationRecordingMatch<R, E>(
|
|
recorded: R,
|
|
expected: E,
|
|
_ message: @autoclosure () -> String = "",
|
|
file: StaticString = #file,
|
|
line: UInt = #line)
|
|
where
|
|
R: Collection,
|
|
E: Collection,
|
|
R.Element == E.Element,
|
|
R.Element: Equatable
|
|
{
|
|
XCTAssertTrue(
|
|
valueObservationRecordingMatch(recorded: recorded, expected: expected),
|
|
"Unexpected recording \(Array(recorded)) - \(message())",
|
|
file: file, line: line)
|
|
}
|
|
|
|
func valueObservationRecordingMatch<R, E>(
|
|
recorded: R,
|
|
expected: E)
|
|
-> Bool
|
|
where R: Collection,
|
|
E: Collection,
|
|
R.Element == E.Element,
|
|
R.Element: Equatable
|
|
{
|
|
guard let first = recorded.first else {
|
|
return expected.isEmpty
|
|
}
|
|
|
|
return expected.indices.lazy
|
|
.filter { expected[$0] == first }
|
|
.contains {
|
|
valueObservationRecordingMatch(
|
|
recorded: recorded.drop(while: { $0 == first }),
|
|
expected: expected[$0...].dropFirst())
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - GRDBTestCase + ValueObservationExpectation
|
|
|
|
extension GRDBTestCase {
|
|
func assertValueObservation<Reducer: ValueReducer>(
|
|
_ observation: ValueObservation<Reducer>,
|
|
records expectedValues: [Reducer.Value],
|
|
setup: (Database) throws -> Void,
|
|
recordedUpdates: @escaping (Database) throws -> Void,
|
|
file: StaticString = #file,
|
|
line: UInt = #line)
|
|
throws
|
|
where Reducer.Value: Equatable
|
|
{
|
|
func test(
|
|
observation: ValueObservation<Reducer>,
|
|
scheduling scheduler: some ValueObservationScheduler,
|
|
testValueDispatching: @escaping () -> Void) throws
|
|
{
|
|
func testRecordingEqualWhenWriteAfterStart(writer: some DatabaseWriter) throws {
|
|
try writer.write(setup)
|
|
|
|
var value: Reducer.Value?
|
|
let recorder = observation.record(
|
|
in: writer,
|
|
scheduling: scheduler,
|
|
onChange: {
|
|
testValueDispatching()
|
|
value = $0
|
|
})
|
|
|
|
// Test that initial value is set when scheduler is immediate
|
|
if scheduler.immediateInitialValue() {
|
|
XCTAssertNotNil(value)
|
|
}
|
|
|
|
// Perform writes after start
|
|
try writer.writeWithoutTransaction(recordedUpdates)
|
|
|
|
let expectation = recorder.next(expectedValues.count)
|
|
let values = try wait(for: expectation, timeout: 5)
|
|
XCTAssertEqual(
|
|
values, expectedValues,
|
|
"\(#function), \(writer), \(scheduler)", file: file, line: line)
|
|
}
|
|
|
|
func testRecordingEqualWhenWriteAfterFirstValue(writer: some DatabaseWriter) throws {
|
|
try writer.write(setup)
|
|
|
|
var valueCount = 0
|
|
var value: Reducer.Value?
|
|
let recorder = observation.record(
|
|
in: writer,
|
|
scheduling: scheduler,
|
|
onChange: { [unowned writer] in
|
|
testValueDispatching()
|
|
valueCount += 1
|
|
if valueCount == 1 {
|
|
// Perform writes after initial value
|
|
try! writer.writeWithoutTransaction(recordedUpdates)
|
|
}
|
|
value = $0
|
|
})
|
|
|
|
// Test that initial value is set when scheduler is immediate
|
|
if scheduler.immediateInitialValue() {
|
|
XCTAssertNotNil(value)
|
|
}
|
|
|
|
let expectation = recorder.next(expectedValues.count)
|
|
let values = try wait(for: expectation, timeout: 5)
|
|
XCTAssertEqual(
|
|
values, expectedValues,
|
|
"\(#function), \(writer), \(scheduler)", file: file, line: line)
|
|
}
|
|
|
|
func testRecordingMatchWhenWriteAfterStart(writer: some DatabaseWriter) throws {
|
|
try writer.write(setup)
|
|
|
|
var value: Reducer.Value?
|
|
let recorder = observation.record(
|
|
in: writer,
|
|
scheduling: scheduler,
|
|
onChange: {
|
|
testValueDispatching()
|
|
value = $0
|
|
})
|
|
|
|
// Test that initial value is set when scheduler is immediate
|
|
if scheduler.immediateInitialValue() {
|
|
XCTAssertNotNil(value)
|
|
}
|
|
|
|
try writer.writeWithoutTransaction(recordedUpdates)
|
|
|
|
let recordedValues: [Reducer.Value]
|
|
let lastExpectedValue = expectedValues.last!
|
|
let waitForLast = expectedValues.firstIndex(of: lastExpectedValue) == expectedValues.count - 1
|
|
if waitForLast {
|
|
// Optimization!
|
|
let expectation = recorder.prefix(until: { $0 == lastExpectedValue } )
|
|
recordedValues = try wait(for: expectation, timeout: 5)
|
|
} else {
|
|
// Slow!
|
|
assertionFailure("Please rewrite your test, because it is too slow: make sure the last expected value is unique.")
|
|
let expectation = recorder
|
|
.prefix(expectedValues.count + 2 /* pool may perform double initial fetch */)
|
|
.inverted
|
|
recordedValues = try wait(for: expectation, timeout: 5)
|
|
}
|
|
|
|
if scheduler.immediateInitialValue() {
|
|
XCTAssertEqual(recordedValues.first, expectedValues.first)
|
|
}
|
|
|
|
assertValueObservationRecordingMatch(
|
|
recorded: recordedValues,
|
|
expected: expectedValues,
|
|
"\(#function), \(writer), \(scheduler)", file: file, line: line)
|
|
}
|
|
|
|
func testRecordingMatchWhenWriteAfterFirstValue(writer: some DatabaseWriter) throws {
|
|
try writer.write(setup)
|
|
|
|
var valueCount = 0
|
|
var value: Reducer.Value?
|
|
let recorder = observation.record(
|
|
in: writer,
|
|
scheduling: scheduler,
|
|
onChange: { [unowned writer] in
|
|
testValueDispatching()
|
|
valueCount += 1
|
|
if valueCount == 1 {
|
|
// Perform writes after initial value
|
|
try! writer.writeWithoutTransaction(recordedUpdates)
|
|
}
|
|
value = $0
|
|
})
|
|
|
|
// Test that initial value is set when scheduler is immediate
|
|
if scheduler.immediateInitialValue() {
|
|
XCTAssertNotNil(value)
|
|
}
|
|
|
|
let recordedValues: [Reducer.Value]
|
|
let lastExpectedValue = expectedValues.last!
|
|
let waitForLast = expectedValues.firstIndex(of: lastExpectedValue) == expectedValues.count - 1
|
|
if waitForLast {
|
|
// Optimization!
|
|
let expectation = recorder.prefix(until: { $0 == lastExpectedValue } )
|
|
recordedValues = try wait(for: expectation, timeout: 5)
|
|
} else {
|
|
// Slow!
|
|
assertionFailure("Please rewrite your test, because it is too slow: make sure the last expected value is unique.")
|
|
let expectation = recorder
|
|
.prefix(expectedValues.count + 2 /* pool may perform double initial fetch */)
|
|
.inverted
|
|
recordedValues = try wait(for: expectation, timeout: 5)
|
|
}
|
|
|
|
XCTAssertEqual(recordedValues.first, expectedValues.first)
|
|
|
|
assertValueObservationRecordingMatch(
|
|
recorded: recordedValues,
|
|
expected: expectedValues,
|
|
"\(#function), \(writer), \(scheduler)", file: file, line: line)
|
|
}
|
|
|
|
try testRecordingEqualWhenWriteAfterStart(writer: DatabaseQueue())
|
|
try testRecordingEqualWhenWriteAfterFirstValue(writer: DatabaseQueue())
|
|
|
|
try testRecordingEqualWhenWriteAfterStart(writer: makeDatabaseQueue())
|
|
try testRecordingEqualWhenWriteAfterFirstValue(writer: makeDatabaseQueue())
|
|
|
|
if observation.requiresWriteAccess {
|
|
try testRecordingEqualWhenWriteAfterStart(writer: makeDatabasePool())
|
|
try testRecordingEqualWhenWriteAfterFirstValue(writer: makeDatabasePool())
|
|
} else {
|
|
// DatabasePool may miss some changes
|
|
try testRecordingMatchWhenWriteAfterStart(writer: makeDatabasePool())
|
|
try testRecordingMatchWhenWriteAfterFirstValue(writer: makeDatabasePool())
|
|
}
|
|
}
|
|
|
|
do {
|
|
let key = DispatchSpecificKey<()>()
|
|
DispatchQueue.main.setSpecific(key: key, value: ())
|
|
|
|
try test(
|
|
observation: observation,
|
|
scheduling: .immediate,
|
|
testValueDispatching: { XCTAssertNotNil(DispatchQueue.getSpecific(key: key)) })
|
|
}
|
|
|
|
do {
|
|
let key = DispatchSpecificKey<()>()
|
|
DispatchQueue.main.setSpecific(key: key, value: ())
|
|
|
|
try test(
|
|
observation: observation,
|
|
scheduling: .async(onQueue: .main),
|
|
testValueDispatching: { XCTAssertNotNil(DispatchQueue.getSpecific(key: key)) })
|
|
}
|
|
|
|
do {
|
|
let queue = DispatchQueue(label: "custom")
|
|
let key = DispatchSpecificKey<()>()
|
|
queue.setSpecific(key: key, value: ())
|
|
|
|
try test(
|
|
observation: observation,
|
|
scheduling: .async(onQueue: queue),
|
|
testValueDispatching: { XCTAssertNotNil(DispatchQueue.getSpecific(key: key)) })
|
|
}
|
|
}
|
|
|
|
func assertValueObservation<Reducer: ValueReducer, Failure: Error>(
|
|
_ observation: ValueObservation<Reducer>,
|
|
fails testFailure: (Failure, any DatabaseWriter) throws -> Void,
|
|
setup: (Database) throws -> Void,
|
|
file: StaticString = #file,
|
|
line: UInt = #line)
|
|
throws
|
|
{
|
|
func test(
|
|
observation: ValueObservation<Reducer>,
|
|
scheduling scheduler: some ValueObservationScheduler,
|
|
testErrorDispatching: @escaping () -> Void) throws
|
|
{
|
|
func test(writer: some DatabaseWriter) throws {
|
|
try writer.write(setup)
|
|
|
|
let recorder = observation.record(
|
|
in: writer,
|
|
scheduling: scheduler,
|
|
onError: { _ in testErrorDispatching() })
|
|
|
|
let (_, error) = try wait(for: recorder.failure(), timeout: 5)
|
|
if let error = error as? Failure {
|
|
try testFailure(error, writer)
|
|
} else {
|
|
throw error
|
|
}
|
|
}
|
|
|
|
try test(writer: DatabaseQueue())
|
|
try test(writer: makeDatabaseQueue())
|
|
try test(writer: makeDatabasePool())
|
|
}
|
|
|
|
do {
|
|
let key = DispatchSpecificKey<()>()
|
|
DispatchQueue.main.setSpecific(key: key, value: ())
|
|
|
|
try test(
|
|
observation: observation,
|
|
scheduling: .immediate,
|
|
testErrorDispatching: { XCTAssertNotNil(DispatchQueue.getSpecific(key: key)) })
|
|
}
|
|
|
|
do {
|
|
let key = DispatchSpecificKey<()>()
|
|
DispatchQueue.main.setSpecific(key: key, value: ())
|
|
|
|
try test(
|
|
observation: observation,
|
|
scheduling: .async(onQueue: .main),
|
|
testErrorDispatching: { XCTAssertNotNil(DispatchQueue.getSpecific(key: key)) })
|
|
}
|
|
|
|
do {
|
|
let queue = DispatchQueue(label: "custom")
|
|
let key = DispatchSpecificKey<()>()
|
|
queue.setSpecific(key: key, value: ())
|
|
|
|
try test(
|
|
observation: observation,
|
|
scheduling: .async(onQueue: queue),
|
|
testErrorDispatching: { XCTAssertNotNil(DispatchQueue.getSpecific(key: key)) })
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - ValueObservationExpectations
|
|
|
|
public enum ValueObservationExpectations { }
|
|
|
|
extension ValueObservationExpectations {
|
|
|
|
// MARK: Inverted
|
|
|
|
public struct Inverted<Base: ValueObservationExpectation>: ValueObservationExpectation {
|
|
let base: Base
|
|
|
|
public func _setup(_ expectation: XCTestExpectation) {
|
|
base._setup(expectation)
|
|
expectation.isInverted.toggle()
|
|
}
|
|
|
|
public func get() throws -> Base.Output {
|
|
try base.get()
|
|
}
|
|
}
|
|
|
|
// MARK: NextOne
|
|
|
|
public struct NextOne<Value>: ValueObservationExpectation {
|
|
let recorder: ValueObservationRecorder<Value>
|
|
|
|
public func _setup(_ expectation: XCTestExpectation) {
|
|
recorder.fulfillOnValue(expectation, includingConsumed: false, isIncluded: { _ in true })
|
|
}
|
|
|
|
public func get() throws -> Value {
|
|
try recorder.value { (_, error, remainingValues, consume) in
|
|
if let next = remainingValues.first {
|
|
consume(1)
|
|
return next
|
|
}
|
|
if let error {
|
|
throw error
|
|
} else {
|
|
throw ValueRecordingError.notEnoughValues
|
|
}
|
|
}
|
|
}
|
|
|
|
public var inverted: NextOneInverted<Value> {
|
|
return NextOneInverted(recorder: recorder)
|
|
}
|
|
}
|
|
|
|
// MARK: NextOneInverted
|
|
|
|
public struct NextOneInverted<Value>: ValueObservationExpectation {
|
|
let recorder: ValueObservationRecorder<Value>
|
|
|
|
public func _setup(_ expectation: XCTestExpectation) {
|
|
expectation.isInverted = true
|
|
recorder.fulfillOnValue(expectation, includingConsumed: false, isIncluded: { _ in true })
|
|
}
|
|
|
|
public func get() throws {
|
|
try recorder.value { (_, error, remainingValues, consume) in
|
|
if remainingValues.isEmpty == false {
|
|
return
|
|
}
|
|
if let error {
|
|
throw error
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: Next
|
|
|
|
public struct Next<Value>: ValueObservationExpectation {
|
|
let recorder: ValueObservationRecorder<Value>
|
|
let count: Int
|
|
|
|
init(recorder: ValueObservationRecorder<Value>, count: Int) {
|
|
precondition(count >= 0, "Invalid negative count")
|
|
self.recorder = recorder
|
|
self.count = count
|
|
}
|
|
|
|
public func _setup(_ expectation: XCTestExpectation) {
|
|
if count == 0 {
|
|
// Such an expectation is immediately fulfilled, by essence.
|
|
expectation.expectedFulfillmentCount = 1
|
|
expectation.fulfill()
|
|
} else {
|
|
expectation.expectedFulfillmentCount = count
|
|
recorder.fulfillOnValue(expectation, includingConsumed: false, isIncluded: { _ in true })
|
|
}
|
|
}
|
|
|
|
public func get() throws -> [Value] {
|
|
try recorder.value { (_, error, remainingValues, consume) in
|
|
if remainingValues.count >= count {
|
|
consume(count)
|
|
return Array(remainingValues.prefix(count))
|
|
}
|
|
if let error {
|
|
throw error
|
|
} else {
|
|
throw ValueRecordingError.notEnoughValues
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: Prefix
|
|
|
|
public struct Prefix<Value>: ValueObservationExpectation {
|
|
let recorder: ValueObservationRecorder<Value>
|
|
let expectedFulfillmentCount: Int
|
|
let isIncluded: (Value) -> Bool
|
|
|
|
init(recorder: ValueObservationRecorder<Value>, expectedFulfillmentCount: Int, isIncluded: @escaping (Value) -> Bool) {
|
|
precondition(expectedFulfillmentCount >= 0, "Invalid negative count")
|
|
self.recorder = recorder
|
|
self.expectedFulfillmentCount = expectedFulfillmentCount
|
|
self.isIncluded = isIncluded
|
|
}
|
|
|
|
public func _setup(_ expectation: XCTestExpectation) {
|
|
if expectedFulfillmentCount == 0 {
|
|
// Such an expectation is immediately fulfilled, by essence.
|
|
expectation.expectedFulfillmentCount = 1
|
|
expectation.fulfill()
|
|
} else {
|
|
expectation.expectedFulfillmentCount = expectedFulfillmentCount
|
|
recorder.fulfillOnValue(expectation, includingConsumed: true, isIncluded: isIncluded)
|
|
}
|
|
}
|
|
|
|
public func get() throws -> [Value] {
|
|
if expectedFulfillmentCount == 0 {
|
|
return []
|
|
}
|
|
|
|
return try recorder.value { (values, error, remainingValues, consume) in
|
|
let includedValues = values.filter(isIncluded)
|
|
if includedValues.count >= expectedFulfillmentCount {
|
|
let matchedCount = values
|
|
.indices
|
|
.filter { isIncluded(values[$0]) }
|
|
.prefix(expectedFulfillmentCount)
|
|
.last! + 1
|
|
let extraCount = max(matchedCount + remainingValues.count - values.count, 0)
|
|
consume(extraCount)
|
|
return Array(values.prefix(matchedCount))
|
|
}
|
|
if let error {
|
|
throw error
|
|
}
|
|
consume(remainingValues.count)
|
|
return values
|
|
}
|
|
}
|
|
|
|
public var inverted: Inverted<Self> {
|
|
return Inverted(base: self)
|
|
}
|
|
}
|
|
|
|
// MARK: Failure
|
|
|
|
public struct Failure<Value>: ValueObservationExpectation {
|
|
let recorder: ValueObservationRecorder<Value>
|
|
|
|
public func _setup(_ expectation: XCTestExpectation) {
|
|
recorder.fulfillOnError(expectation)
|
|
}
|
|
|
|
public func get() throws -> (values: [Value], error: Error) {
|
|
try recorder.value { (values, error, remainingValues, consume) in
|
|
if let error {
|
|
consume(remainingValues.count)
|
|
return (values: values, error: error)
|
|
} else {
|
|
throw ValueRecordingError.notFailed
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Convenience
|
|
|
|
extension XCTestExpectation {
|
|
fileprivate func fulfill(count: Int) {
|
|
for _ in 0..<count {
|
|
fulfill()
|
|
}
|
|
}
|
|
}
|