diff --git a/MMMLoadable.podspec b/MMMLoadable.podspec index eae5847..64cc569 100644 --- a/MMMLoadable.podspec +++ b/MMMLoadable.podspec @@ -6,7 +6,7 @@ Pod::Spec.new do |s| s.name = "MMMLoadable" - s.version = "1.9.0" + s.version = "1.10.0" s.summary = "A simple model for async calculations" s.description = "#{s.summary}." s.homepage = "https://github.com/mediamonks/#{s.name}" @@ -17,7 +17,6 @@ Pod::Spec.new do |s| s.ios.deployment_target = '11.0' s.watchos.deployment_target = '3.0' s.tvos.deployment_target = '10.0' - s.osx.deployment_target = '10.12' s.subspec 'ObjC' do |ss| ss.source_files = [ "Sources/#{s.name}ObjC/*.{h,m}" ] diff --git a/Sources/MMMLoadable/MMMLoadable.swift b/Sources/MMMLoadable/MMMLoadable.swift index 38fb562..350f7d9 100644 --- a/Sources/MMMLoadable/MMMLoadable.swift +++ b/Sources/MMMLoadable/MMMLoadable.swift @@ -1,6 +1,6 @@ // // MMMLoadable. Part of MMMTemple. -// Copyright (C) 2016-2020 MediaMonks. All rights reserved. +// Copyright (C) 2016-2023 MediaMonks. All rights reserved. // import Foundation diff --git a/Sources/MMMLoadable/MMMLoadableChain.swift b/Sources/MMMLoadable/MMMLoadableChain.swift new file mode 100644 index 0000000..9f150ae --- /dev/null +++ b/Sources/MMMLoadable/MMMLoadableChain.swift @@ -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 + )) + } + } + } +} diff --git a/Tests/MMMLoadableChainTestCase.swift b/Tests/MMMLoadableChainTestCase.swift new file mode 100644 index 0000000..63f8e0a --- /dev/null +++ b/Tests/MMMLoadableChainTestCase.swift @@ -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]) + } + } +} diff --git a/Tests/MMMLoadableTestCase.swift b/Tests/MMMLoadableTestCase.swift index a35a7c5..34098bc 100644 --- a/Tests/MMMLoadableTestCase.swift +++ b/Tests/MMMLoadableTestCase.swift @@ -1,13 +1,13 @@ // // MMMLoadable. Part of MMMTemple. -// Copyright (C) 2016-2020 MediaMonks. All rights reserved. +// Copyright (C) 2016-2023 MediaMonks. All rights reserved. // import MMMCommonCore import MMMLoadable import XCTest -class MMMLoadableTestCase: XCTestCase { +public final class MMMLoadableTestCase: XCTestCase { func testGroup() {