diff --git a/MMMLoadable.podspec b/MMMLoadable.podspec index b84fce9..2987e3d 100644 --- a/MMMLoadable.podspec +++ b/MMMLoadable.podspec @@ -1,12 +1,12 @@ # # MMMLoadable. Part of MMMTemple. -# Copyright (C) 2015-2020 MediaMonks. All rights reserved. +# Copyright (C) 2015-2022 MediaMonks. All rights reserved. # Pod::Spec.new do |s| s.name = "MMMLoadable" - s.version = "1.7.1" + s.version = "1.8.0" s.summary = "A simple model for async calculations" s.description = "#{s.summary}." s.homepage = "https://github.com/mediamonks/#{s.name}" @@ -16,7 +16,7 @@ Pod::Spec.new do |s| s.ios.deployment_target = '11.0' s.watchos.deployment_target = '3.0' - s.tvos.deployment_target = '9.0' + s.tvos.deployment_target = '10.0' s.osx.deployment_target = '10.12' s.subspec 'ObjC' do |ss| @@ -40,9 +40,10 @@ Pod::Spec.new do |s| end s.test_spec 'Tests' do |ss| - ss.ios.deployment_target = '11.0' + ss.ios.deployment_target = '11.0' ss.source_files = "Tests/*.{m,swift}" + ss.requires_app_host = true end - s.default_subspec = 'ObjC', 'Swift' + s.default_subspec = 'ObjC', 'Swift' end diff --git a/Sources/MMMLoadable/MMMLoadable.swift b/Sources/MMMLoadable/MMMLoadable.swift index c2b83bd..5d0108e 100644 --- a/Sources/MMMLoadable/MMMLoadable.swift +++ b/Sources/MMMLoadable/MMMLoadable.swift @@ -10,9 +10,8 @@ import MMMCommonCore @_exported import MMMLoadableObjC #endif -extension MMMLoadableState: CustomDebugStringConvertible { - - public var debugDescription: String { NSStringFromMMMLoadableState(self) } +extension MMMLoadableState: CustomStringConvertible { + public var description: String { NSStringFromMMMLoadableState(self) } } extension MMMPureLoadableProtocol { diff --git a/Sources/MMMLoadableObjC/MMMLoadable+Subclasses.h b/Sources/MMMLoadableObjC/MMMLoadable+Subclasses.h index 168d473..871c196 100644 --- a/Sources/MMMLoadableObjC/MMMLoadable+Subclasses.h +++ b/Sources/MMMLoadableObjC/MMMLoadable+Subclasses.h @@ -14,7 +14,7 @@ NS_ASSUME_NONNULL_BEGIN /** - * Parts of the base lodable accessible to subclasses. + * Parts of the base loadable accessible to subclasses. */ @interface MMMLoadable (Subclasses) @@ -129,7 +129,7 @@ NS_ASSUME_NONNULL_BEGIN /** Note that the contents of the group can be changed by subclasses any time after the initialization * (and this can be done more than once), so a nil can be passed to the designated initializer and then - * this property can be adjusted after subobjects are initialized. */ + * this property can be adjusted after sub-objects are initialized. */ @property (nonatomic, readwrite) NSArray> *loadables; - (void)notifyDidChange; diff --git a/Sources/MMMLoadableObjC/MMMLoadable.h b/Sources/MMMLoadableObjC/MMMLoadable.h index 583aa2b..fca52c4 100644 --- a/Sources/MMMLoadableObjC/MMMLoadable.h +++ b/Sources/MMMLoadableObjC/MMMLoadable.h @@ -49,7 +49,7 @@ typedef NS_CLOSED_ENUM(NSInteger, MMMLoadableState) { MMMLoadableStateSyncing, /** - * The object has been successfuly synced and its contents (promises — value) is available now. + * The object has been successfully synced and its contents (promises — value) is available now. * (Promises — 'resolved'.) * (A name is a bit longer than just 'synced' here so it's easier to differentiate from 'syncing'.) */ @@ -79,7 +79,7 @@ NS_SWIFT_NAME(MMMPureLoadableProtocol) @protocol MMMPureLoadable /** The state of the loadable, such as 'idle' or 'syncing'. - * The 'loadable' prefix allows to have a 'state' property for somethingn else in the same object. */ + * The 'loadable' prefix allows to have a 'state' property for something else in the same object. */ @property (nonatomic, readonly) MMMLoadableState loadableState; /** @@ -182,7 +182,7 @@ typedef void (^MMMLoadableObserverDidChangeBlock)(id loadable); * * Both initializers return `nil` when the passed `loadable` is `nil`. This is handy when resubscribing to (possibile) * different loadables many times and storing an instance of the observer in the same variable over and over: there is - * no need to check the target loadable and/or nillify the previous observer to unsubscribe. + * no need to check the target loadable and/or nullify the previous observer to unsubscribe. */ @interface MMMLoadableObserver : NSObject @@ -209,7 +209,7 @@ typedef void (^MMMLoadableObserverDidChangeBlock)(id loadable); @end /** - * An implementation of a lodable that might be used as a base. + * An implementation of a loadable that might be used as a base. * Subclasses must override 'isContentsAvailable' and 'doSync', the latter being called from implementation * of sync/syncIfNeeded, see `MMMLoadable+Subclasses.h`. * (Only the general declaration is open here so you can inherit it in the classes exposed to the end user, @@ -252,7 +252,7 @@ typedef void (^MMMLoadableObserverDidChangeBlock)(id loadable); @end /** - * `MMMLoadable` with simple autorefresh logic. + * `MMMLoadable` with simple auto-refresh logic. * Again, see `MMMLoadable+Subclasses.h` if you want to see how to override things. */ @interface MMMAutosyncLoadable : MMMLoadable @@ -265,46 +265,85 @@ typedef void (^MMMLoadableObserverDidChangeBlock)(id loadable); * Defines how sync failures in child loadables of a loadable group affect the sync state of the whole group. */ typedef NS_ENUM(NSInteger, MMMLoadableGroupFailurePolicy) { - - /** - * The whole group is considered "failed to sync" when any of the child loadables fails to sync. - * (This is the default behavior that most of the code relies on.) + + /** + * The loadable state of the group in this mode is: + * - 'synced successfully', when **all** loadables in the group are synced successfully, + * - 'failed to sync', when there is at least one loadable in the group that has failed to sync; + * - 'syncing', when any of the loadables in the group is syncing and none has failed yet. + * + * The contents of the group is considered available, if it is available in **all** objects of the group. */ - MMMLoadableGroupFailurePolicyStrict, + MMMLoadableGroupFailurePolicyStrict, /** - * The whole group never fails to sync, not even when all the loadables within the group fail. - * (In this case it's assumed that the user code will inspect the children and decide what to do.) - */ - MMMLoadableGroupFailurePolicyNever + * This mode is **deprecated** in favor of `MMMLoadableGroupFailurePolicyAny`. + * + * The loadable state of the group in this mode is: + * - 'syncing', when at least one of the loadables in the group is still syncing; + * - 'synced successfully' otherwise. + * - 'contentsAvailable' is `YES` when it is `YES` for all the objects in the group. + * + * Just like in the "strict" mode, the contents of the group is considered available, if it is available in + * **all** objects of the group. (This is something that was making the mode confusing in addition to breaking + * the contract that the contents is available for 'synced successfully' objects.) + */ + MMMLoadableGroupFailurePolicyNever +}; + +/** + * Defines how the composite state of the loadable group depends on the states of its children. + */ +typedef NS_ENUM(NSInteger, MMMLoadableGroupMode) { + + /** + * The loadable state of the group in this mode is: + * - 'synced successfully', when **all** loadables in the group are synced successfully, + * - 'failed to sync', when there is at least one loadable in the group that has failed to sync; + * - 'syncing', when any of the loadables in the group is syncing and none has failed yet. + * + * The contents of the group is considered available, if it is available in **all** objects of the group. + */ + MMMLoadableGroupModeAll, + + /** + * The loadable state of the group in this mode is: + * - 'synced successfully', when at least one of the loadables in the group is synced successfully, + * - 'syncing', when at least one of the loadables in the group is syncing. + * - 'failed to sync', when **all** of the loadables in the group have failed to sync; + * - 'contentsAvailable' is `true`, if there is at least one object in the group with 'contentsAvailable' + */ + MMMLoadableGroupModeAny, + + /** To map the deprecated "never" failure policy. */ + MMMLoadableGroupModeDeprecated NS_REFINED_FOR_SWIFT }; /** * Allows to treat several "pure" loadables as one. * - * Can be used standalone or subclassed (see `MMMLoadable+Subclasses.h` in this case.) - * - * Its loadable state in case of a "strict" failure policy (default) is: - * - 'synced succesfully', when all the loadables in the group are synced successfully, - * - 'failed to sync', when at least one of the loadables in the group has failed to sync; - * - 'syncing', when at least one of the loadables in the group is still syncing and none has failed yet. - * - * The loadable state in case of "never" failure policy is: - * - 'syncing', when at least one of the loadables in the group is still syncing; - * - 'synced succesfully' otherwise. + * The group supports 2 different modes: + * - "all" (this is the default), where the group is considered 'synced successfully' when **all** of its children + * are 'synced successfully'; + * - "at least one", where the group is considered 'synced successfully' when **at least one** of its child + * loadables is 'synced successfully'. * - * Regardless of the failure policy 'contentsAvailable' is `YES` when it is `YES` for all the objects in the group. + * This can be used standalone or subclassed (see `MMMLoadable+Subclasses.h` in this case.) * - * The 'did change' event of the group is called when when the `loadableState` of the whole object changes or - * when all the objects are loaded, then every time any of the objects emits 'did change'. + * The 'did change' event of the group is triggered when its `loadableState` changes or, if all objects are loaded, + * then every time any of the them emits 'did change'. */ @interface MMMPureLoadableGroup : NSObject -- (id)initWithLoadables:(nullable NSArray> *)loadables - failurePolicy:(MMMLoadableGroupFailurePolicy)failurePolicy; +- (instancetype)initWithLoadables:(nullable NSArray> *)loadables + mode:(MMMLoadableGroupMode)mode NS_SWIFT_NAME("init(_:mode)") NS_DESIGNATED_INITIALIZER; -/** Convenience initializer using the "strict" failure policy for compatibility with the current code. */ -- (id)initWithLoadables:(nullable NSArray> *)loadables; +/** Convenience initializer using the "all" mode (former "strict" failure policy) for compatibility. */ +- (instancetype)initWithLoadables:(nullable NSArray> *)loadables; + +/** **Deprecated.** */ +- (instancetype)initWithLoadables:(nullable NSArray> *)loadables + failurePolicy:(MMMLoadableGroupFailurePolicy)failurePolicy; - (id)init NS_UNAVAILABLE; @@ -315,20 +354,13 @@ typedef NS_ENUM(NSInteger, MMMLoadableGroupFailurePolicy) { * * Can be used standalone or subclassed (see `MMMLoadable+Subclasses.h` in this case.) * - * In addition to the behaviour of `MMMPureLoadableGroup`: + * In addition to the behavior of `MMMPureLoadableGroup`: * - `needsSync` is YES, if the same property is YES for at least one object in the group; * - `sync` and `syncIfNeeded` methods call the corresponding methods of every object in the group supporting them * (note that some time before we required all objects in a "non-pure" group to support syncing, but it's not the case * anymore). */ @interface MMMLoadableGroup : MMMPureLoadableGroup - -- (id)initWithLoadables:(nullable NSArray> *)loadables - failurePolicy:(MMMLoadableGroupFailurePolicy)failurePolicy NS_DESIGNATED_INITIALIZER; - -/** Convenience initializer using the "strict" failure policy for compatibility with the current code. */ -- (id)initWithLoadables:(nullable NSArray> *)loadables; - @end /** diff --git a/Sources/MMMLoadableObjC/MMMLoadable.m b/Sources/MMMLoadableObjC/MMMLoadable.m index 4712aeb..4c5aabc 100644 --- a/Sources/MMMLoadableObjC/MMMLoadable.m +++ b/Sources/MMMLoadableObjC/MMMLoadable.m @@ -289,7 +289,7 @@ - (NSString *)description { #ifdef __HAS_UI_KIT__ -@implementation MMMAutosyncLoadable { +@implementation MMMAutosyncLoadable { MMMWeakProxy *_autosyncTimerProxy; NSTimer *_autosyncTimer; } @@ -562,17 +562,21 @@ @implementation MMMPureLoadableGroup { MMMObserverHub> *_observerHub; MMMLoadableObserverSelectorProxy *_observerProxy; MMMLoadableGroupFailurePolicy _failurePolicy; + MMMLoadableGroupMode _mode; } @synthesize loadables = _loadables; @synthesize contentsAvailable = _contentsAvailable; @synthesize loadableState = _loadableState; -- (id)initWithLoadables:(NSArray *)loadables failurePolicy:(MMMLoadableGroupFailurePolicy)failurePolicy { +// For some reason Swift overrides the designated initializer and we cannot call it from our convenience methods, +// thus this "common initializer". + +- (id)_initWithLoadables:(nullable NSArray> *)loadables mode:(MMMLoadableGroupMode)mode { if (self = [super init]) { - _failurePolicy = failurePolicy; + _mode = mode; // We don't want our subclasses to override our `loadableDidChange:` so we don't subscribe directly. _observerProxy = [[MMMLoadableObserverSelectorProxy alloc] @@ -581,15 +585,32 @@ - (id)initWithLoadables:(NSArray *)loadables failurePolicy:(MMMLoadableGroupFail ]; _observerHub = [[MMMObserverHub alloc] initWithObservable:self]; - + [self setLoadables:loadables]; } return self; } +- (id)initWithLoadables:(nullable NSArray> *)loadables mode:(MMMLoadableGroupMode)mode { + return [self _initWithLoadables:loadables mode:mode]; +} + +- (id)initWithLoadables:(NSArray *)loadables failurePolicy:(MMMLoadableGroupFailurePolicy)failurePolicy { + MMMLoadableGroupMode mode; + switch (failurePolicy) { + case MMMLoadableGroupFailurePolicyStrict: + mode = MMMLoadableGroupModeAll; + break; + case MMMLoadableGroupFailurePolicyNever: + mode = MMMLoadableGroupModeDeprecated; + break; + } + return [self _initWithLoadables:loadables mode:mode]; +} + - (id)initWithLoadables:(nullable NSArray> *)loadables { - return [self initWithLoadables:loadables failurePolicy:MMMLoadableGroupFailurePolicyStrict]; + return [self _initWithLoadables:loadables mode:MMMLoadableGroupModeAll]; } - (void)dealloc { @@ -640,56 +661,88 @@ - (void)updateState { NSInteger syncedCount = 0; NSInteger syncingCount = 0; for (id loadable in _loadables) { - switch (_failurePolicy) { - case MMMLoadableGroupFailurePolicyStrict: - if (loadable.loadableState == MMMLoadableStateDidFailToSync) { - failedCount++; - } else if (loadable.loadableState == MMMLoadableStateDidSyncSuccessfully) { - syncedCount++; - } else if (loadable.loadableState == MMMLoadableStateSyncing) { - syncingCount++; - } - break; - - case MMMLoadableGroupFailurePolicyNever: - if (loadable.loadableState == MMMLoadableStateDidFailToSync + switch (_mode) { + case MMMLoadableGroupModeAll: + case MMMLoadableGroupModeAny: + if (loadable.loadableState == MMMLoadableStateDidFailToSync) { + failedCount++; + } else if (loadable.loadableState == MMMLoadableStateDidSyncSuccessfully) { + syncedCount++; + } else if (loadable.loadableState == MMMLoadableStateSyncing) { + syncingCount++; + } + break; + + case MMMLoadableGroupModeDeprecated: + if (loadable.loadableState == MMMLoadableStateDidFailToSync || loadable.loadableState == MMMLoadableStateDidSyncSuccessfully ) { - syncedCount++; - } else if (loadable.loadableState == MMMLoadableStateSyncing) { - syncingCount++; - } - break; - } + syncedCount++; + } else if (loadable.loadableState == MMMLoadableStateSyncing) { + syncingCount++; + } + break; + } } BOOL newContentsAvailable; if (_loadables.count == 0) { - // Assuming no content in case the group is empty. - // This way initializing the group with an empty array (something we do for convenience before setting the actual array) - // won't lead to a useless 'did change' notification. + // Assuming no contents in case the group is empty. This way initializing the group with an empty array + // (something we do for convenience before setting the actual array) won't lead to a 'did change' notification. newContentsAvailable = NO; } else { - newContentsAvailable = YES; - for (id loadable in _loadables) { - if (![loadable isContentsAvailable]) { + switch (_mode) { + case MMMLoadableGroupModeAll: + case MMMLoadableGroupModeDeprecated: + // All should have contents available. (Yes, that was the rule in "never" mode as well.) + newContentsAvailable = YES; + for (id loadable in _loadables) { + if (![loadable isContentsAvailable]) { + newContentsAvailable = NO; + break; + } + } + break; + case MMMLoadableGroupModeAny: + // At least one should have contents available. newContentsAvailable = NO; + for (id loadable in _loadables) { + if ([loadable isContentsAvailable]) { + newContentsAvailable = YES; + break; + } + } break; - } } } - + MMMLoadableState newLoadableState; - if (failedCount > 0) { - newLoadableState = MMMLoadableStateDidFailToSync; - } else if (syncingCount > 0) { - newLoadableState = MMMLoadableStateSyncing; - } else if (syncedCount > 0 && syncedCount == _loadables.count) { - newLoadableState = MMMLoadableStateDidSyncSuccessfully; - } else { - // Again, avoiding 'did sync' for empty groups, preferring 'idle'. - // Same reason as for 'contentsAvailable' in the above. - newLoadableState = MMMLoadableStateIdle; + switch (_mode) { + case MMMLoadableGroupModeAll: + case MMMLoadableGroupModeDeprecated: + if (failedCount > 0) { + newLoadableState = MMMLoadableStateDidFailToSync; + } else if (syncingCount > 0) { + newLoadableState = MMMLoadableStateSyncing; + } else if (syncedCount > 0 && syncedCount == _loadables.count) { + newLoadableState = MMMLoadableStateDidSyncSuccessfully; + } else { + // Again, avoiding 'did sync' for empty groups, preferring 'idle'. + // Same reason as for 'contentsAvailable' in the above. + newLoadableState = MMMLoadableStateIdle; + } + break; + case MMMLoadableGroupModeAny: + if (syncingCount > 0) { + newLoadableState = MMMLoadableStateSyncing; + } else if (syncedCount > 0) { + newLoadableState = MMMLoadableStateDidSyncSuccessfully; + } else if (failedCount > 0 && failedCount == _loadables.count) { + newLoadableState = MMMLoadableStateDidFailToSync; + } else { + newLoadableState = MMMLoadableStateIdle; + } + break; } // Not checking for the change in contentsAvailable when notifying because by our contract @@ -742,14 +795,6 @@ - (NSString *)description { // @implementation MMMLoadableGroup -- (id)initWithLoadables:(NSArray> *)loadables failurePolicy:(MMMLoadableGroupFailurePolicy)failurePolicy { - return [super initWithLoadables:loadables failurePolicy:failurePolicy]; -} - -- (id)initWithLoadables:(nullable NSArray> *)loadables { - return [self initWithLoadables:loadables failurePolicy:MMMLoadableGroupFailurePolicyStrict]; -} - - (void)setLoadables:(NSArray> *)loadables { [super setLoadables:loadables]; diff --git a/Sources/MMMLoadableObjC/MMMLoadableImage.h b/Sources/MMMLoadableObjC/MMMLoadableImage.h index a313339..07fd848 100644 --- a/Sources/MMMLoadableObjC/MMMLoadableImage.h +++ b/Sources/MMMLoadableObjC/MMMLoadableImage.h @@ -38,7 +38,7 @@ API_AVAILABLE(ios(11)) @interface MMMNamedLoadableImage : MMMLoadable @@ -49,7 +49,7 @@ API_AVAILABLE(ios(11)) @interface MMMImmediateLoadableImage : MMMLoadable @@ -77,7 +77,7 @@ API_AVAILABLE(ios(11)) @interface MMMTestLoadableImage : MMMTestLoadable visible publically. + * As always, this is meant to be used only in the implementation, with only id visible publicly. */ API_AVAILABLE(ios(11)) @interface MMMLoadableImageProxy : MMMLoadableProxy diff --git a/Tests/MMMLoadableTestCase.swift b/Tests/MMMLoadableTestCase.swift new file mode 100644 index 0000000..a35a7c5 --- /dev/null +++ b/Tests/MMMLoadableTestCase.swift @@ -0,0 +1,229 @@ +// +// MMMLoadable. Part of MMMTemple. +// Copyright (C) 2016-2020 MediaMonks. All rights reserved. +// + +import MMMCommonCore +import MMMLoadable +import XCTest + +class MMMLoadableTestCase: XCTestCase { + + func testGroup() { + + XCTAssertEqual( + groupTruthTable(mode: .all), + """ + ### isContentsAvailable + + false <- [false, false] + false <- [false, true] + false <- [true, false] + true <- [true, true] + + ### loadableState + + idle <- [idle, idle] + idle <- [idle, did-sync-successfully] + idle <- [did-sync-successfully, idle] + syncing <- [idle, syncing] + syncing <- [syncing, idle] + syncing <- [syncing, syncing] + syncing <- [syncing, did-sync-successfully] + syncing <- [did-sync-successfully, syncing] + did-sync-successfully <- [did-sync-successfully, did-sync-successfully] + did-fail-to-sync <- [idle, did-fail-to-sync] + did-fail-to-sync <- [syncing, did-fail-to-sync] + did-fail-to-sync <- [did-sync-successfully, did-fail-to-sync] + did-fail-to-sync <- [did-fail-to-sync, idle] + did-fail-to-sync <- [did-fail-to-sync, syncing] + did-fail-to-sync <- [did-fail-to-sync, did-sync-successfully] + did-fail-to-sync <- [did-fail-to-sync, did-fail-to-sync] + + """ + ) + + XCTAssertEqual( + groupTruthTable(mode: .any), + """ + ### isContentsAvailable + + false <- [false, false] + true <- [false, true] + true <- [true, false] + true <- [true, true] + + ### loadableState + + idle <- [idle, idle] + idle <- [idle, did-fail-to-sync] + idle <- [did-fail-to-sync, idle] + syncing <- [idle, syncing] + syncing <- [syncing, idle] + syncing <- [syncing, syncing] + syncing <- [syncing, did-sync-successfully] + syncing <- [syncing, did-fail-to-sync] + syncing <- [did-sync-successfully, syncing] + syncing <- [did-fail-to-sync, syncing] + did-sync-successfully <- [idle, did-sync-successfully] + did-sync-successfully <- [did-sync-successfully, idle] + did-sync-successfully <- [did-sync-successfully, did-sync-successfully] + did-sync-successfully <- [did-sync-successfully, did-fail-to-sync] + did-sync-successfully <- [did-fail-to-sync, did-sync-successfully] + did-fail-to-sync <- [did-fail-to-sync, did-fail-to-sync] + + """ + ) + + // This is what the former `MMMLoadableGroupFailurePolicyNever` would produce. + // Note the issue with isContentsAvailable still depending on all objects which causes it to be `false` + // when the composite state is `did-sync-successfully`. + XCTAssertEqual( + groupTruthTable(mode: .__deprecated), + """ + ### isContentsAvailable + + false <- [false, false] + false <- [false, true] + false <- [true, false] + true <- [true, true] + + ### loadableState + + idle <- [idle, idle] + idle <- [idle, did-sync-successfully] + idle <- [idle, did-fail-to-sync] + idle <- [did-sync-successfully, idle] + idle <- [did-fail-to-sync, idle] + syncing <- [idle, syncing] + syncing <- [syncing, idle] + syncing <- [syncing, syncing] + syncing <- [syncing, did-sync-successfully] + syncing <- [syncing, did-fail-to-sync] + syncing <- [did-sync-successfully, syncing] + syncing <- [did-fail-to-sync, syncing] + did-sync-successfully <- [did-sync-successfully, did-sync-successfully] + did-sync-successfully <- [did-sync-successfully, did-fail-to-sync] + did-sync-successfully <- [did-fail-to-sync, did-sync-successfully] + did-sync-successfully <- [did-fail-to-sync, did-fail-to-sync] + + """ + ) + } + + private func groupTruthTable(mode: MMMLoadableGroupMode) -> String { + + var result: String = "" + + print("### isContentsAvailable\n", to: &result) + print( + truthTable( + groupMode: mode, + values: [false, true] + ) { group, pairs in + // isContentsAvailable Is recalculated only with state changes, thus need to flip + // the composite state back and forth. + for (o, v) in pairs { + o.isContentsAvailable = v + o.setSyncing() + } + for (o, _) in pairs { + o.setDidFailToSyncWithError(nil) + } + return group.isContentsAvailable + }, + to: &result + ) + + print("\n### loadableState\n", to: &result) + print( + truthTable( + groupMode: mode, + values: [.idle, .syncing, .didFailToSync, .didSyncSuccessfully] as [MMMLoadableState] + ) { group, pairs in + for (o, v) in pairs { + o.loadableState = v + } + return group.loadableState + }, + to: &result + ) + + return result + } + + private struct TruthTable: CustomStringConvertible { + + public let rows: [TruthTableRow] + + public var description: String { + // Need to sorting because we are randomizing order while testing. + let sorted = rows.sorted { + if $0.output < $1.output { + return true + } else if $0.output == $1.output { + // Arrays are not Comparable. + for (a, b) in zip($0.inputs, $1.inputs) { + if a < b { + return true + } else if a > b { + return false + } + } + return false + } else { + return false + } + } + return sorted.map(String.init(describing:)).joined(separator: "\n") + } + } + + private struct TruthTableRow: Equatable, CustomStringConvertible { + + public let inputs: [T] + public let output: T + + public init(_ inputs: [T], _ output: T) { + self.inputs = inputs + self.output = output + } + + public var description: String { + "\(output) <- \(inputs)" + } + } + + private func truthTable( + groupMode: MMMLoadableGroupMode, + values: [T], + assign: (MMMLoadableGroup, [(MMMTestLoadable, T)]) -> T + ) -> TruthTable { + + let c1 = MMMTestLoadable() + let c2 = MMMTestLoadable() + let group = MMMLoadableGroup(loadables: [c1, c2], mode: groupMode) + + var rows: [TruthTableRow] = [] + // Shuffling to avoid dependency on the order. + for v1 in values.shuffled() { + for v2 in values.shuffled() { + let output = assign(group, [(c1, v1), (c2, v2)]) + rows.append(.init([v1, v2], output)) + } + } + return .init(rows: rows) + } +} + +extension MMMLoadableState: Comparable { + public static func < (lhs: MMMLoadableState, rhs: MMMLoadableState) -> Bool { + lhs.rawValue < rhs.rawValue + } +} + +extension Bool: Comparable { + public static func < (lhs: Bool, rhs: Bool) -> Bool { + !lhs && rhs + } +}