diff --git a/Mobius.xcodeproj/project.pbxproj b/Mobius.xcodeproj/project.pbxproj index ef08f4ea..33243074 100644 --- a/Mobius.xcodeproj/project.pbxproj +++ b/Mobius.xcodeproj/project.pbxproj @@ -63,7 +63,6 @@ 2DF4C30620DBDD5C00A4B6DE /* AnonymousDisposable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB287BF209995410043B530 /* AnonymousDisposable.swift */; }; 2DF4C30720DBDD5C00A4B6DE /* EventSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB287C0209995410043B530 /* EventSource.swift */; }; 2DF4C30920DBDD5C00A4B6DE /* Mobius.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB287C3209995410043B530 /* Mobius.swift */; }; - 2DF4C30B20DBDD5C00A4B6DE /* EventProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB287C6209995410043B530 /* EventProcessor.swift */; }; 2DF4C30C20DBDD5C00A4B6DE /* MobiusController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB287C8209995410043B530 /* MobiusController.swift */; }; 2DF4C30D20DBDD5C00A4B6DE /* MobiusLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB287C9209995410043B530 /* MobiusLogger.swift */; }; 2DF4C30E20DBDD5C00A4B6DE /* Lock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB287CA209995410043B530 /* Lock.swift */; }; @@ -127,7 +126,6 @@ 5BB2881D2099957D0043B530 /* First.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB287BB209995410043B530 /* First.swift */; }; 5BB2881E209995810043B530 /* EventSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB287C0209995410043B530 /* EventSource.swift */; }; 5BB28821209995810043B530 /* Mobius.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB287C3209995410043B530 /* Mobius.swift */; }; - 5BB28824209995810043B530 /* EventProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB287C6209995410043B530 /* EventProcessor.swift */; }; 5BB28826209995810043B530 /* MobiusController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB287C8209995410043B530 /* MobiusController.swift */; }; 5BB28827209995810043B530 /* MobiusLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB287C9209995410043B530 /* MobiusLogger.swift */; }; 5BB28828209995810043B530 /* Lock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB287CA209995410043B530 /* Lock.swift */; }; @@ -140,7 +138,6 @@ 5BB2886E20999AD60043B530 /* MobiusIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB2884C20999ACE0043B530 /* MobiusIntegrationTests.swift */; }; 5BB2886F20999AD60043B530 /* TestingUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB2884D20999ACE0043B530 /* TestingUtil.swift */; }; 5BB2887120999AD60043B530 /* LoggingInitiateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB2884F20999ACE0043B530 /* LoggingInitiateTests.swift */; }; - 5BB2887220999AD60043B530 /* EventProcessorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB2885020999ACE0043B530 /* EventProcessorTests.swift */; }; 5BB2887520999AD60043B530 /* AnyEventSourceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB2885320999ACE0043B530 /* AnyEventSourceTests.swift */; }; 5BB2887620999AD60043B530 /* NextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB2885420999ACE0043B530 /* NextTests.swift */; }; 5BB2887720999AD60043B530 /* FirstTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB2885520999ACE0043B530 /* FirstTests.swift */; }; @@ -383,7 +380,6 @@ 5BB287BF209995410043B530 /* AnonymousDisposable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnonymousDisposable.swift; sourceTree = ""; }; 5BB287C0209995410043B530 /* EventSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EventSource.swift; sourceTree = ""; }; 5BB287C3209995410043B530 /* Mobius.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Mobius.swift; sourceTree = ""; }; - 5BB287C6209995410043B530 /* EventProcessor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EventProcessor.swift; sourceTree = ""; }; 5BB287C8209995410043B530 /* MobiusController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MobiusController.swift; sourceTree = ""; }; 5BB287C9209995410043B530 /* MobiusLogger.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MobiusLogger.swift; sourceTree = ""; }; 5BB287CA209995410043B530 /* Lock.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Lock.swift; sourceTree = ""; }; @@ -396,7 +392,6 @@ 5BB2884C20999ACE0043B530 /* MobiusIntegrationTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MobiusIntegrationTests.swift; sourceTree = ""; }; 5BB2884D20999ACE0043B530 /* TestingUtil.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestingUtil.swift; sourceTree = ""; }; 5BB2884F20999ACE0043B530 /* LoggingInitiateTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoggingInitiateTests.swift; sourceTree = ""; }; - 5BB2885020999ACE0043B530 /* EventProcessorTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EventProcessorTests.swift; sourceTree = ""; }; 5BB2885320999ACE0043B530 /* AnyEventSourceTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnyEventSourceTests.swift; sourceTree = ""; }; 5BB2885420999ACE0043B530 /* NextTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NextTests.swift; sourceTree = ""; }; 5BB2885520999ACE0043B530 /* FirstTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FirstTests.swift; sourceTree = ""; }; @@ -706,7 +701,6 @@ 5BB287B6209995410043B530 /* Connection.swift */, 5BB287B7209995410043B530 /* Consumer.swift */, 2D3F26EC237B02B8004C2B75 /* AsyncDispatchQueueConnectable.swift */, - 5BB287C6209995410043B530 /* EventProcessor.swift */, 5BB287BB209995410043B530 /* First.swift */, 5BB287CA209995410043B530 /* Lock.swift */, 2D15DF72238EA4DD00E78B27 /* LoggingAdaptors.swift */, @@ -784,7 +778,6 @@ 5BB2884A20999ACE0043B530 /* AnyMobiusLoggerTests.swift */, 5BB2885720999ACE0043B530 /* CompositeDisposableTests.swift */, 5BB2885920999ACE0043B530 /* ConnectablePublisherTests.swift */, - 5BB2885020999ACE0043B530 /* EventProcessorTests.swift */, 2D58735F238EC60F001F21ED /* EventRouterDisposalLogicalRaceRegressionTest.swift */, 5BB2885520999ACE0043B530 /* FirstTests.swift */, 5BB2884F20999ACE0043B530 /* LoggingInitiateTests.swift */, @@ -1227,7 +1220,6 @@ 5B4A369B21107D2600279C7D /* AnyEventSource.swift in Sources */, 2DF4C30220DBDD5800A4B6DE /* ConnectablePublisher.swift in Sources */, 2DF4C2FF20DBDD5800A4B6DE /* Consumer.swift in Sources */, - 2DF4C30B20DBDD5C00A4B6DE /* EventProcessor.swift in Sources */, 2DF4C2FC20DBDD5800A4B6DE /* Next.swift in Sources */, 2DF4C30D20DBDD5C00A4B6DE /* MobiusLogger.swift in Sources */, 2DF4C30020DBDD5800A4B6DE /* MobiusLoop.swift in Sources */, @@ -1309,7 +1301,6 @@ 02D6755323D1E822008200AF /* EnumRoute.swift in Sources */, 5B1F103C210F5EBC0067193C /* ActionConnectable.swift in Sources */, 5BB2881B2099957D0043B530 /* MobiusHooks.swift in Sources */, - 5BB28824209995810043B530 /* EventProcessor.swift in Sources */, 5BB2881C2099957D0043B530 /* ConnectablePublisher.swift in Sources */, 02D0DDC02366F08300A1CE4C /* EffectHandler.swift in Sources */, 5BB2881E209995810043B530 /* EventSource.swift in Sources */, @@ -1323,7 +1314,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 5BB2887220999AD60043B530 /* EventProcessorTests.swift in Sources */, 2DB61AFD23A8F485009E55DB /* NonReentrancyTests.swift in Sources */, 5BB2886F20999AD60043B530 /* TestingUtil.swift in Sources */, 02C406242374260700BD7ED8 /* EffectRouterTests.swift in Sources */, diff --git a/MobiusCore/Source/EventProcessor.swift b/MobiusCore/Source/EventProcessor.swift deleted file mode 100644 index ab771ee0..00000000 --- a/MobiusCore/Source/EventProcessor.swift +++ /dev/null @@ -1,104 +0,0 @@ -// Copyright (c) 2020 Spotify AB. -// -// Licensed to the Apache Software Foundation (ASF) under one -// or more contributor license agreements. See the NOTICE file -// distributed with this work for additional information -// regarding copyright ownership. The ASF licenses this file -// to you under the Apache License, Version 2.0 (the -// "License"); you may not use this file except in compliance -// with the License. You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, -// software distributed under the License is distributed on an -// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -// KIND, either express or implied. See the License for the -// specific language governing permissions and limitations -// under the License. - -import Foundation - -/// Internal class that manages the atomic state updates and notifications of model changes when processing of events -/// via the Update function. -class EventProcessor: Disposable, CustomDebugStringConvertible { - let update: Update - let publisher: ConnectablePublisher> - let access: ConcurrentAccessDetector - - private var currentModel: Model? - private var queuedEvents = [Event]() - private var disposed = false - - public var debugDescription: String { - return access.guard { - let modelDescription: String - if let currentModel = currentModel { - modelDescription = String(reflecting: currentModel) - } else { - modelDescription = "nil" - } - return "<\(modelDescription), \(queuedEvents)>" - } - } - - init( - update: Update, - publisher: ConnectablePublisher>, - accessGuard: ConcurrentAccessDetector = ConcurrentAccessDetector() - ) { - self.update = update - self.publisher = publisher - access = accessGuard - } - - func start(from model: Model, effects: [Effect]) { - access.guard { - currentModel = model - - publisher.post(Next.next(model, effects: effects)) - - for event in queuedEvents { - accept(event) - } - - queuedEvents = [] - } - } - - func accept(_ event: Event) { - access.guard { - guard !disposed else { return } - - if let current = self.currentModel { - let next = self.update.update(model: current, event: event) - - if let newModel = next.model { - self.currentModel = newModel - } - - self.publisher.post(next) - } else { - self.queuedEvents.append(event) - } - } - } - - func dispose() { - access.guard { - disposed = true - publisher.dispose() - } - } - - func readCurrentModel() -> Model? { - return access.guard { currentModel } - } - - var latestModel: Model { - guard let model = readCurrentModel() else { - preconditionFailure("latestModel may only be invoked after start()") - } - return model - } -} diff --git a/MobiusCore/Source/Mobius.swift b/MobiusCore/Source/Mobius.swift index b3f109e4..6502e529 100644 --- a/MobiusCore/Source/Mobius.swift +++ b/MobiusCore/Source/Mobius.swift @@ -201,13 +201,13 @@ public enum Mobius { /// - initialModel: The model the loop should start with. /// - effects: Zero or more effects to execute immediately. public func start(from initialModel: Model, effects: [Effect] = []) -> MobiusLoop { - return MobiusLoop.createLoop( + return MobiusLoop( + model: initialModel, update: update, - effectHandler: effectHandler, - initialModel: initialModel, - effects: effects, eventSource: eventSource, eventConsumerTransformer: eventConsumerTransformer, + effectHandler: effectHandler, + effects: effects, logger: logger ) } diff --git a/MobiusCore/Source/MobiusLoop.swift b/MobiusCore/Source/MobiusLoop.swift index b078df68..272293ed 100644 --- a/MobiusCore/Source/MobiusLoop.swift +++ b/MobiusCore/Source/MobiusLoop.swift @@ -19,39 +19,82 @@ import Foundation -/// - Callout(Instantiating): Use `Mobius.loop(update:effectHandler:)` to create an instance. -public final class MobiusLoop: Disposable, CustomDebugStringConvertible { - private let eventProcessor: EventProcessor - private let consumeEvent: Consumer - private let modelPublisher: ConnectablePublisher - private let disposable: Disposable - private var disposed = false - private var access: ConcurrentAccessDetector +/// A `MobiusLoop` is the core encapsulation of business logic in Mobius. +/// +/// It stores a current model, applies incoming events, processes the resulting effects, and broadcasts model changes +/// to observers. +/// +/// Use `Mobius.loop(update:effectHandler:)` to create an instance. +public final class MobiusLoop: Disposable { + private let access = ConcurrentAccessDetector() private var workBag: WorkBag - public var debugDescription: String { - return access.guard { - if disposed { - return "disposed \(type(of: self))!" - } - return "\(type(of: self)) \(eventProcessor)" - } - } + private var effectConnection: Connection! = nil + private var consumeEvent: Consumer! = nil + private let modelPublisher: ConnectablePublisher + + private var model: Model + + private var disposable: CompositeDisposable? init( - eventProcessor: EventProcessor, - consumeEvent: @escaping Consumer, - modelPublisher: ConnectablePublisher, - disposable: Disposable, - accessGuard: ConcurrentAccessDetector, - workBag: WorkBag + model: Model, + update: Update, + eventSource: AnyEventSource, + eventConsumerTransformer: ConsumerTransformer, + effectHandler: AnyConnectable, + effects: [Effect], + logger: AnyMobiusLogger ) { - self.eventProcessor = eventProcessor - self.consumeEvent = consumeEvent - self.modelPublisher = modelPublisher - self.disposable = disposable - self.access = accessGuard + let loggingUpdate = logger.wrap(update: update.updateClosure) + + let workBag = WorkBag(accessGuard: access) self.workBag = workBag + + self.model = model + self.modelPublisher = ConnectablePublisher(accessGuard: access) + + // consumeEvent is the closure that processes an event and handles the model and effect updates. It needs to + // be a closure so that it can be transformed by eventConsumerTransformer, and to handle ownership correctly: + // consumeEvent holds on to the update function and workbag, and also holds self while its work bag submission + // is queued. + // + // Originally the processNext(...) invocation was wrapped in a method, but that just spread things out more. + let consumeEvent = eventConsumerTransformer { [unowned self] event in + // Note: captures self strongly until the block is serviced by the workBag + let processNext = self.processNext + workBag.submit { + // Note: we must read self.model inside the submit block, since other queued blocks may have executed + // between submitting and getting here. + // This is an unowned read of `self`, but at this point `self` is being kept alive by the local + // `processNext`. + let model = self.model + processNext(loggingUpdate(model, event)) + } + workBag.service() + } + self.consumeEvent = consumeEvent + + // These must be set up after consumeEvent, which refers to self; that’s why they need to be IUOs. + self.effectConnection = effectHandler.connect(consumeEvent) + let eventSourceDisposable = eventSource.subscribe(consumer: consumeEvent) + + self.disposable = CompositeDisposable(disposables: [ + effectConnection, + modelPublisher, + eventSourceDisposable, + ]) + + // Prime the modelPublisher, and queue up any initial effects. + processNext(.next(model, effects: effects)) + + // When we’re fully initialized, we can process any initial effects plus events that may have been queued up + // by the effect handler or event source when we connected to them. + workBag.start() + } + + deinit { + dispose() } /// Add an observer of model changes to this loop. If `getMostRecentModel()` is non-nil, @@ -69,29 +112,30 @@ public final class MobiusLoop: Disposable, CustomDebugStri } public func dispose() { - return access.guard { - if !disposed { - modelPublisher.dispose() - eventProcessor.dispose() - disposable.dispose() - disposed = true - } + access.guard { + let disposable = self.disposable + self.disposable = nil + disposable?.dispose() } } - deinit { - dispose() - } - + /// Extract the latest model from the loop. + /// + /// This property is discouraged; in general, it is preferable to add an observer with `addObserver`. public var latestModel: Model { - return access.guard { eventProcessor.latestModel } + return access.guard { model } } + /// Send an event to the loop. + /// + /// This method is discouraged; in general, it’s preferable to inject events from an `EventSource` or `EffectHandler`. + /// + /// - Parameter event: The event to dispatch. public func dispatchEvent(_ event: Event) { return access.guard { guard !disposed else { // Callers are responsible for ensuring dispatchEvent is never entered after dispose. - MobiusHooks.errorHandler("\(Self.debugTag): event submitted after dispose", #file, #line) + MobiusHooks.errorHandler("\(debugTag): event submitted after dispose", #file, #line) } unguardedDispatchEvent(event) @@ -101,84 +145,52 @@ public final class MobiusLoop: Disposable, CustomDebugStri /// Like `dispatchEvent`, but without asserting that the loop hasn’t been disposed. /// /// This should not be used directly, but is useful in constructing asynchronous wrappers around loops (like - /// `MobiusController`, where the eventConsumerTransformer is used to implement equivalent async-safe assertions). + /// `MobiusController`, where the `eventConsumerTransformer` is used to implement equivalent async-safe assertions). public func unguardedDispatchEvent(_ event: Event) { consumeEvent(event) } - // swiftlint:disable:next function_parameter_count - static func createLoop( - update: Update, - effectHandler: EffectHandler, - initialModel: Model, - effects: [Effect], - eventSource: AnyEventSource, - eventConsumerTransformer: ConsumerTransformer, - logger: AnyMobiusLogger - ) -> MobiusLoop where EffectHandler.Input == Effect, EffectHandler.Output == Event { - let accessGuard = ConcurrentAccessDetector() - let loggingUpdate = logger.wrap(update: update) - let workBag = WorkBag(accessGuard: accessGuard) - - // create somewhere for the event processor to push nexts to; later, we'll observe these nexts and - // dispatch models and effects to the right places - let nextPublisher = ConnectablePublisher>(accessGuard: accessGuard) - - // event processor: process events, publish Next:s, retain current model - let eventProcessor = EventProcessor( - update: loggingUpdate, - publisher: nextPublisher, - accessGuard: accessGuard - ) - - let consumeEvent = eventConsumerTransformer { event in + // MARK: - Implemenation details + + /// Apply a `Next`: + /// + /// * Store the new model, if any, in self.model + /// * Post the new model, if any, to observers + /// * Queue up any effects in the workBag + /// * Service the workBag + private func processNext(_ next: Next) { + if let newModel = next.model { + model = newModel + modelPublisher.post(model) + } + + for effect in next.effects { workBag.submit { - eventProcessor.accept(event) + self.effectConnection.accept(effect) } - workBag.service() } + workBag.service() + } - // effect handler: handle effects, push events to the event processor - let effectHandlerConnection = effectHandler.connect(consumeEvent) - - let eventSourceDisposable = eventSource.subscribe(consumer: consumeEvent) + /// Test whether the loop has been disposed. + private var disposed: Bool { + return disposable == nil + } - // model observer support - let modelPublisher = ConnectablePublisher() + /// A string to identify the MobiusLoop; currently the type name including type arguments. + fileprivate var debugTag: String { + return "\(type(of: self))" + } +} - // ensure model updates get published and effects dispatched to the effect handler - let nextConsumer: Consumer> = { next in - if let model = next.model { - modelPublisher.post(model) +extension MobiusLoop: CustomDebugStringConvertible { + public var debugDescription: String { + return access.guard { + if disposed { + return "disposed \(debugTag)!" } - next.effects.forEach({ (effect: Effect) in - workBag.submit { - effectHandlerConnection.accept(effect) - } - }) - workBag.service() + return "\(debugTag){ \(String(reflecting: model)) }" } - let nextConnection = nextPublisher.connect(to: nextConsumer) - - // everything is hooked up, start processing! - eventProcessor.start(from: initialModel, effects: effects) - - return MobiusLoop( - eventProcessor: eventProcessor, - consumeEvent: consumeEvent, - modelPublisher: modelPublisher, - disposable: CompositeDisposable(disposables: [ - eventSourceDisposable, - nextConnection, - effectHandlerConnection, - ]), - accessGuard: accessGuard, - workBag: workBag - ) - } - - private static var debugTag: String { - return "MobiusLoop<\(Model.self), \(Event.self), \(Effect.self)>" } } diff --git a/MobiusCore/Source/WorkBag.swift b/MobiusCore/Source/WorkBag.swift index 8664d4ec..cc3fb397 100644 --- a/MobiusCore/Source/WorkBag.swift +++ b/MobiusCore/Source/WorkBag.swift @@ -32,14 +32,29 @@ import Foundation final class WorkBag { typealias WorkItem = () -> Void + private enum State { + case notStarted + case idle + case servicing + } + private var queue = [WorkItem]() - private var servicing = false + private var state = State.notStarted private var access: ConcurrentAccessDetector init(accessGuard: ConcurrentAccessDetector = ConcurrentAccessDetector()) { access = accessGuard } + /// Start the workbag. Must be called once and once only in order to process events. + func start() { + access.guard { + precondition(state == .notStarted) + state = .idle + } + service() + } + /// Submit an action to be executed. func submit(_ action: @escaping WorkItem) { access.guard { @@ -53,9 +68,9 @@ final class WorkBag { /// call, until there is no more pending work. func service() { access.guard { - guard !servicing else { return } - servicing = true - defer { servicing = false } + guard state == .idle else { return } + state = .servicing + defer { state = .idle } while let action = next() { action() diff --git a/MobiusCore/Test/EventProcessorTests.swift b/MobiusCore/Test/EventProcessorTests.swift deleted file mode 100644 index 7ca6bae0..00000000 --- a/MobiusCore/Test/EventProcessorTests.swift +++ /dev/null @@ -1,123 +0,0 @@ -// Copyright (c) 2020 Spotify AB. -// -// Licensed to the Apache Software Foundation (ASF) under one -// or more contributor license agreements. See the NOTICE file -// distributed with this work for additional information -// regarding copyright ownership. The ASF licenses this file -// to you under the Apache License, Version 2.0 (the -// "License"); you may not use this file except in compliance -// with the License. You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, -// software distributed under the License is distributed on an -// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -// KIND, either express or implied. See the License for the -// specific language governing permissions and limitations -// under the License. - -import Foundation -@testable import MobiusCore -import Nimble -import Quick - -class EventProcessorTests: QuickSpec { - // swiftlint:disable function_body_length - override func spec() { - describe("EventProcessor") { - var eventProcessor: EventProcessor! - var nextPublisher: ConnectablePublisher>! - var consumer: Consumer>! - var receivedModels: [Int]! - - beforeEach { - nextPublisher = ConnectablePublisher() - eventProcessor = EventProcessor(update: self.testUpdate, publisher: nextPublisher) - - receivedModels = [] - consumer = { - if let model = $0.model { - receivedModels.append(model) - } - } - - nextPublisher.connect(to: consumer) - } - - describe("publishing") { - it("should post the first to the publisher as a next") { - eventProcessor.start(from: 1, effects: []) - - expect(receivedModels).to(equal([1])) - } - - it("should post nexts to the publisher") { - eventProcessor.start(from: 1, effects: []) - - eventProcessor.accept(10) - eventProcessor.accept(200) - expect(receivedModels).to(equal([1, 11, 211])) - } - } - - describe("current model") { - it("should initially be empty") { - expect(eventProcessor.readCurrentModel()).to(beNil()) - } - - context("given a start from 1") { - beforeEach { - eventProcessor.start(from: 1, effects: []) - } - - it("should track the current model from start") { - expect(eventProcessor.readCurrentModel()).to(equal(1)) - } - - it("should track the current model from updates") { - eventProcessor.accept(99) - - expect(eventProcessor.readCurrentModel()).to(equal(100)) - } - } - } - - it("should queue events until started") { - eventProcessor.accept(80) - eventProcessor.accept(400) - - eventProcessor.start(from: 1, effects: []) - - expect(receivedModels).to(equal([1, 81, 481])) - } - - it("should dispose publisher on dispose") { - eventProcessor.dispose() - - expect(nextPublisher.disposed).to(beTrue()) - } - - describe("debug description") { - context("when the event processor has no model") { - it("should produce the appropriate debug description") { - let description = String(reflecting: eventProcessor) - expect(description).to(equal("Optional()")) - } - } - - context("when the event processor has a First") { - it("should produce the appropriate debug description") { - eventProcessor.start(from: 1, effects: [2, 3]) - let description = String(reflecting: eventProcessor) - expect(description).to(contain("Optional(<1")) // Due to synced queue its hard to test a processor with events - } - } - } - } - } - - let testUpdate = Update { model, event in - Next.next(model + event) - } -} diff --git a/MobiusCore/Test/MobiusLoopTests.swift b/MobiusCore/Test/MobiusLoopTests.swift index 08ead3d9..8f39b810 100644 --- a/MobiusCore/Test/MobiusLoopTests.swift +++ b/MobiusCore/Test/MobiusLoopTests.swift @@ -100,7 +100,11 @@ class MobiusLoopTests: QuickSpec { loop.addObserver(modelObserver) - expect(receivedModels).to(equal(["the beginning-one-two-three"])) + // receivedModels contains the concatenation of received events, but the order is randomized, so + // we need to turn it into a collection + let components = receivedModels.first!.split(separator: "-") + expect(components.first).to(equal("the beginning")) + expect(components.sorted()).to(equal(["one", "the beginning", "three", "two"])) } } @@ -138,35 +142,6 @@ class MobiusLoopTests: QuickSpec { } } - describe("dispose dependencies") { - var eventProcessor: TestEventProcessor! - var modelPublisher: ConnectablePublisher! - var disposable: ConnectablePublisher! - - beforeEach { - eventProcessor = TestEventProcessor( - update: Update { _, _ in .noChange }, - publisher: ConnectablePublisher() - ) - modelPublisher = ConnectablePublisher() - disposable = ConnectablePublisher() - - loop = MobiusLoop( - eventProcessor: eventProcessor, - modelPublisher: modelPublisher, - disposable: disposable - ) - } - - it("should dispose all of the dependencies") { - loop.dispose() - - expect(eventProcessor.disposed).to(equal(true)) - expect(modelPublisher.disposed).to(equal(true)) - expect(disposable.disposed).to(equal(true)) - } - } - describe("logging") { var logger: TestMobiusLogger! @@ -206,7 +181,7 @@ class MobiusLoopTests: QuickSpec { Next.noChange } - builder = Mobius.loop(update: update, effectHandler: TestConnectableProtocolImpl()) + builder = Mobius.loop(update: update, effectHandler: SimpleTestConnectable()) } it("should produce a builder") { @@ -216,22 +191,15 @@ class MobiusLoopTests: QuickSpec { } describe("debug description") { - let eventProcessorDebugDescription = "blah" beforeEach { - let publisher = ConnectablePublisher() - let eventProcessor = TestEventProcessor( - update: Update { _, _ in .noChange }, - publisher: ConnectablePublisher>() - ) - eventProcessor.desiredDebugDescription = eventProcessorDebugDescription - - loop = MobiusLoop(eventProcessor: eventProcessor, modelPublisher: publisher, disposable: publisher) + loop = Mobius.loop(update: { _, _ in .noChange }, effectHandler: SimpleTestConnectable()) + .start(from: "hello") } context("when not disposed") { - it("should describe the loop and the event processor") { + it("should describe the loop and the model") { let description = String(describing: loop) - expect(description).to(equal("Optional(MobiusLoop \(eventProcessorDebugDescription))")) + expect(description).to(equal(#"Optional(MobiusLoop{ "hello" })"#)) } } @@ -306,21 +274,3 @@ private class EagerEffectHandler: Connectable { return RecordingTestConnectable().connect(consumer) } } - -private class TestEventProcessor: EventProcessor { - var disposed = false - override func dispose() { - disposed = true - } - - var desiredDebugDescription: String? - public override var debugDescription: String { - return desiredDebugDescription ?? "" - } -} - -private class TestConnectableProtocolImpl: Connectable { - func connect(_: @escaping (String) -> Void) -> Connection { - return Connection(acceptClosure: { _ in }, disposeClosure: {}) - } -} diff --git a/MobiusCore/Test/TestingUtil.swift b/MobiusCore/Test/TestingUtil.swift index 0e75d2b6..ee4fe2fa 100644 --- a/MobiusCore/Test/TestingUtil.swift +++ b/MobiusCore/Test/TestingUtil.swift @@ -21,25 +21,6 @@ import Foundation @testable import MobiusCore import Nimble -extension MobiusLoop { - convenience init( - eventProcessor: EventProcessor, - modelPublisher: ConnectablePublisher, - disposable: Disposable, - accessGuard: ConcurrentAccessDetector = ConcurrentAccessDetector(), - workBag: WorkBag? = nil - ) { - self.init( - eventProcessor: eventProcessor, - consumeEvent: eventProcessor.accept, - modelPublisher: modelPublisher, - disposable: disposable, - accessGuard: accessGuard, - workBag: workBag ?? WorkBag(accessGuard: accessGuard) - ) - } -} - class SimpleTestConnectable: Connectable { var disposed = false diff --git a/MobiusCore/Test/WorkBagTests.swift b/MobiusCore/Test/WorkBagTests.swift index 927d03b3..5f22be04 100644 --- a/MobiusCore/Test/WorkBagTests.swift +++ b/MobiusCore/Test/WorkBagTests.swift @@ -41,6 +41,8 @@ class WorkBagTests: QuickSpec { } it("executes enqueued blocks in order") { + workBag.start() + enqueue("item 1") enqueue("item 2") enqueue("item 3") @@ -50,7 +52,21 @@ class WorkBagTests: QuickSpec { expect(results).to(equal(["item 1", "item 2", "item 3"])) } + it("enqueues but does not execute blocks submitted before start()") { + enqueue("item 1") + enqueue("item 2") + enqueue("item 3") + + expect(results).to(equal([])) + + workBag.start() + + expect(results).to(equal(["item 1", "item 2", "item 3"])) + } + it("can be serviced multiple times") { + workBag.start() + enqueue("item 1") enqueue("item 2") enqueue("item 3") @@ -66,6 +82,8 @@ class WorkBagTests: QuickSpec { } it("doesn’t perform tasks before service is called") { + workBag.start() + enqueue("item 1") enqueue("item 2") enqueue("item 3") @@ -79,6 +97,8 @@ class WorkBagTests: QuickSpec { } it("executes blocks added within a work item during the current service cycle") { + workBag.start() + workBag.submit { enqueue("item 1") } @@ -89,6 +109,8 @@ class WorkBagTests: QuickSpec { } it("performs nested work items strictly after ongoing ones") { + workBag.start() + // Note that here results is an array rather than a set var results = [String]()