-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
5 changed files
with
289 additions
and
5 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,126 @@ | ||
// | ||
// MMMLoadable. Part of MMMTemple. | ||
// Copyright (C) 2023 MediaMonks. All rights reserved. | ||
// | ||
|
||
import Foundation | ||
|
||
/// Helps to sync a bunch of loadables one-by-one, allowing to optionally set up each next loadable based on the data | ||
/// available in the previously synced ones. (Compare to ``MMMLoadableGroup`` syncing its elements in parallel.) | ||
/// | ||
/// Objects in the chain are synced (via `syncIfNeeded()`) one by one starting from the first one. | ||
/// Only the "current" object is observed at a time until it's not syncing anymore. | ||
/// | ||
/// - When the current object is done syncing but **has no contents**, then the chain stops with an error | ||
/// (i.e. chain's own `loadableState` becomes `.didFailToSync` and `isContentsAvailable` is `false`.). | ||
/// | ||
/// - When the current object is done syncing and **does have contents**, then (depending on the value returned | ||
/// by the associated callback) the chain can either: | ||
/// - stop without trying to sync the remaining objects (either with an error or successfully); | ||
/// - or proceed to the next object, if any. | ||
/// | ||
/// Once all objects are successfully synced the chain itself becomes synced successfully, i.e. its `loadableState` | ||
/// becomes `.didSyncSuccessfully` and `isContentsAvailable` transitions to `true`. | ||
public final class MMMLoadableChain: MMMLoadable { | ||
|
||
private let chain: [Item] | ||
|
||
public init(_ chain: [Item]) { | ||
self.chain = chain | ||
} | ||
|
||
public convenience init(_ chain: Item...) { | ||
self.init(chain) | ||
} | ||
|
||
public convenience init(_ chain: [MMMLoadableProtocol]) { | ||
self.init(chain.map { Item($0) }) | ||
} | ||
|
||
public struct Item { | ||
|
||
fileprivate var loadable: any MMMLoadableProtocol | ||
fileprivate var whenContentsAvailable: (() -> NextAction)? | ||
|
||
/// - Parameters: | ||
/// - whenContentsAvailable: An optional callback invoked after the `loadable` is done syncing | ||
/// and has contents available. The callback can, for example, prepare the next objects in the chain | ||
/// or interrupt syncing of the whole chain if there is enough information already let say. | ||
public init( | ||
_ loadable: any MMMLoadableProtocol, | ||
whenContentsAvailable: (() -> NextAction)? = nil | ||
) { | ||
self.loadable = loadable | ||
self.whenContentsAvailable = whenContentsAvailable | ||
} | ||
} | ||
|
||
/// The value returned by a callback that can be optionally associated with each of the loadables in the chain. | ||
/// The value controls the behavior of the chain after the corresponding object is **successfully** synced. | ||
public enum NextAction { | ||
/// The chain should proceed syncing the remaining objects, if any. | ||
/// This is the default used in case an object in the chain has no associated callback. | ||
case proceed | ||
/// The chain should fail with the given error without trying to sync the remaining objects, if any. | ||
case fail(Error) | ||
/// The chain should stop successfully without trying to sync the remaining objects, if any. | ||
case completeSuccessfully | ||
} | ||
|
||
public override func needsSync() -> Bool { | ||
chain.contains { $0.loadable.needsSync } | ||
} | ||
|
||
public override var isContentsAvailable: Bool { | ||
loadableState == .didSyncSuccessfully | ||
} | ||
|
||
public override func doSync() { | ||
currentIndex = chain.startIndex | ||
syncNextLater() | ||
} | ||
|
||
private var currentIndex: Int = 0 | ||
private var waiter: MMMSimpleLoadableWaiter? | ||
|
||
private func syncNextLater() { | ||
DispatchQueue.main.async { [weak self] in | ||
self?.syncNext() | ||
} | ||
} | ||
|
||
private func syncNext() { | ||
|
||
let item = chain[currentIndex] | ||
|
||
let loadable = item.loadable | ||
loadable.syncIfNeeded() | ||
waiter = .whenDoneSyncing(loadable) { [weak self, weak loadable] in | ||
|
||
guard let self, let loadable else { return } | ||
self.waiter = nil | ||
|
||
if loadable.isContentsAvailable { | ||
switch item.whenContentsAvailable?() ?? .proceed { | ||
case .completeSuccessfully: | ||
self.setDidSyncSuccessfully() | ||
case .fail(let error): | ||
self.setFailedToSyncWithError(error) | ||
case .proceed: | ||
self.currentIndex += 1 | ||
if self.currentIndex < chain.endIndex { | ||
self.syncNextLater() | ||
} else { | ||
self.setDidSyncSuccessfully() | ||
} | ||
} | ||
} else { | ||
self.setFailedToSyncWithError(NSError( | ||
domain: self, | ||
message: "Could not sync element #\(currentIndex)", | ||
underlyingError: loadable.error | ||
)) | ||
} | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,159 @@ | ||
// | ||
// Starbucks App. | ||
// Copyright (c) 2023 MediaMonks. All rights reserved. | ||
// | ||
|
||
import MMMLoadable | ||
import XCTest | ||
|
||
public final class MMMLoadableChainTestCase: XCTestCase { | ||
|
||
public func testBasics() { | ||
|
||
let a = MMMTestLoadable() | ||
let b = MMMTestLoadable() | ||
let c = MMMTestLoadable() | ||
|
||
let chain = MMMLoadableChain([a, b, c]) | ||
XCTAssertEqual(a.loadableState, .idle) | ||
XCTAssertEqual(b.loadableState, .idle) | ||
XCTAssertEqual(c.loadableState, .idle) | ||
XCTAssertEqual(chain.loadableState, .idle) | ||
XCTAssert(!chain.isContentsAvailable) | ||
|
||
// When the chain syncs it starts with the first object. | ||
chain.syncIfNeeded() | ||
pump() | ||
XCTAssertEqual(a.loadableState, .syncing) // <-- | ||
XCTAssertEqual(b.loadableState, .idle) | ||
XCTAssertEqual(c.loadableState, .idle) | ||
XCTAssertEqual(chain.loadableState, .syncing) | ||
XCTAssert(!chain.isContentsAvailable) | ||
|
||
// ...and then continues to the next. | ||
a.setDidSyncSuccessfully() | ||
pump() | ||
XCTAssertEqual(a.loadableState, .didSyncSuccessfully) | ||
XCTAssertEqual(b.loadableState, .syncing) // <-- | ||
XCTAssertEqual(c.loadableState, .idle) | ||
XCTAssertEqual(chain.loadableState, .syncing) | ||
XCTAssert(!chain.isContentsAvailable) | ||
|
||
// The whole chain fails to sync as soon as the current object does. | ||
b.setDidFailToSyncWithError(NSError(domain: self, message: "Simulated error")) | ||
pump() | ||
XCTAssertEqual(a.loadableState, .didSyncSuccessfully) | ||
XCTAssertEqual(b.loadableState, .didFailToSync) // <-- | ||
XCTAssertEqual(c.loadableState, .idle) | ||
XCTAssertEqual(chain.loadableState, .didFailToSync) // <-- | ||
XCTAssertEqual( | ||
chain.error?.mmm_description, | ||
"Could not sync element #1 (MMMLoadableChain) > Simulated error (MMMLoadableChainTestCase)" | ||
) | ||
XCTAssert(!chain.isContentsAvailable) | ||
|
||
// When restarted it should continue with the first failed. | ||
chain.syncIfNeeded() | ||
pump() | ||
XCTAssertEqual(a.loadableState, .didSyncSuccessfully) | ||
XCTAssertEqual(b.loadableState, .syncing) // <-- | ||
XCTAssertEqual(c.loadableState, .idle) | ||
XCTAssertEqual(chain.loadableState, .syncing) // <-- | ||
XCTAssertNil(chain.error) | ||
XCTAssert(!chain.isContentsAvailable) | ||
|
||
// Let's sync the last one in advance on its own. | ||
c.setDidSyncSuccessfully() | ||
pump() | ||
XCTAssertEqual(a.loadableState, .didSyncSuccessfully) | ||
XCTAssertEqual(b.loadableState, .syncing) | ||
XCTAssertEqual(c.loadableState, .didSyncSuccessfully) // <-- | ||
XCTAssertEqual(chain.loadableState, .syncing) | ||
XCTAssertNil(chain.error) | ||
XCTAssert(!chain.isContentsAvailable) | ||
|
||
// So the whole chain is ready as soon as `b` is. | ||
b.setDidSyncSuccessfully() | ||
pump() | ||
XCTAssertEqual(a.loadableState, .didSyncSuccessfully) | ||
XCTAssertEqual(b.loadableState, .didSyncSuccessfully) | ||
XCTAssertEqual(c.loadableState, .didSyncSuccessfully) | ||
XCTAssertEqual(chain.loadableState, .didSyncSuccessfully) | ||
XCTAssertNil(chain.error) | ||
XCTAssert(chain.isContentsAvailable) | ||
} | ||
|
||
public func testCallbacks() { | ||
|
||
let actions = [ | ||
.completeSuccessfully, | ||
.proceed, | ||
.fail(NSError(domain: self, message: "Simulated error")) | ||
] as [MMMLoadableChain.NextAction] | ||
|
||
for action in actions.shuffled() { | ||
|
||
let a = MMMTestLoadable() | ||
let b = MMMTestLoadable() | ||
let c = MMMTestLoadable() | ||
|
||
let chain = MMMLoadableChain([ | ||
.init(a), | ||
.init(b) { action }, | ||
.init(c) | ||
]) | ||
|
||
// Let's start with the first object synced already, so it begins with the second. | ||
a.setDidSyncSuccessfully() | ||
chain.syncIfNeeded() | ||
pump() | ||
XCTAssertEqual(a.loadableState, .didSyncSuccessfully) | ||
XCTAssertEqual(b.loadableState, .syncing) // <-- | ||
XCTAssertEqual(c.loadableState, .idle) | ||
XCTAssertEqual(chain.loadableState, .syncing) | ||
XCTAssertNil(chain.error) | ||
XCTAssert(!chain.isContentsAvailable) | ||
|
||
// Now when the second is synced the corresponding callback can control what happens next. | ||
b.setDidSyncSuccessfully() | ||
pump() | ||
switch action { | ||
case .completeSuccessfully: | ||
// The callback can indicate that we have enough info with `a` and `b` already and don't need the rest... | ||
XCTAssertEqual(a.loadableState, .didSyncSuccessfully) | ||
XCTAssertEqual(b.loadableState, .didSyncSuccessfully) | ||
XCTAssertEqual(c.loadableState, .idle) | ||
XCTAssertEqual(chain.loadableState, .didSyncSuccessfully) | ||
XCTAssertNil(chain.error) | ||
XCTAssert(chain.isContentsAvailable) | ||
case .fail: | ||
// ... or it can tell that something is still not enough to sync c even though a and b were properly synced. | ||
XCTAssertEqual(a.loadableState, .didSyncSuccessfully) | ||
XCTAssertEqual(b.loadableState, .didSyncSuccessfully) | ||
XCTAssertEqual(c.loadableState, .idle) | ||
XCTAssertEqual(chain.loadableState, .didFailToSync) | ||
XCTAssertEqual(chain.error?.mmm_description, "Simulated error (MMMLoadableChainTestCase)") | ||
XCTAssert(!chain.isContentsAvailable) | ||
case .proceed: | ||
// And of course the callback can, for example, prepare `c` based on the info from `a` or `b` and | ||
// the ask the chain to proceed. | ||
XCTAssertEqual(a.loadableState, .didSyncSuccessfully) | ||
XCTAssertEqual(b.loadableState, .didSyncSuccessfully) | ||
XCTAssertEqual(c.loadableState, .syncing) | ||
XCTAssertEqual(chain.loadableState, .syncing) | ||
XCTAssertNil(chain.error) | ||
XCTAssert(!chain.isContentsAvailable) | ||
} | ||
} | ||
} | ||
|
||
private func pump(count: Int = 16) { | ||
for _ in 1...count { | ||
let e = expectation(description: "Next cycle of the main queue") | ||
DispatchQueue.main.async { | ||
e.fulfill() | ||
} | ||
wait(for: [e]) | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters