Skip to content

Commit

Permalink
Add MMMLoadableChain
Browse files Browse the repository at this point in the history
  • Loading branch information
aleh committed Jul 23, 2023
1 parent da4a53e commit 38f119b
Show file tree
Hide file tree
Showing 5 changed files with 289 additions and 5 deletions.
3 changes: 1 addition & 2 deletions MMMLoadable.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand All @@ -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}" ]
Expand Down
2 changes: 1 addition & 1 deletion Sources/MMMLoadable/MMMLoadable.swift
Original file line number Diff line number Diff line change
@@ -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
Expand Down
126 changes: 126 additions & 0 deletions Sources/MMMLoadable/MMMLoadableChain.swift
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
))
}
}
}
}
159 changes: 159 additions & 0 deletions Tests/MMMLoadableChainTestCase.swift
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])
}
}
}
4 changes: 2 additions & 2 deletions Tests/MMMLoadableTestCase.swift
Original file line number Diff line number Diff line change
@@ -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() {

Expand Down

0 comments on commit 38f119b

Please sign in to comment.