Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

EventSource as Connectable #211

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 40 additions & 4 deletions MobiusCore/Source/Mobius.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ public enum Mobius {
return Builder(
update: update,
effectHandler: effectHandler,
eventSource: AnyEventSource({ _ in AnonymousDisposable(disposer: {}) }),
eventSource: AnyConnectable { _ in .init(acceptClosure: { _ in }, disposeClosure: {}) },
eventConsumerTransformer: { $0 },
logger: AnyMobiusLogger(NoopLogger())
)
Expand Down Expand Up @@ -104,14 +104,14 @@ public enum Mobius {
public struct Builder<Model, Event, Effect> {
private let update: Update<Model, Event, Effect>
private let effectHandler: AnyConnectable<Effect, Event>
private let eventSource: AnyEventSource<Event>
private let eventSource: AnyConnectable<Model, Event>
private let logger: AnyMobiusLogger<Model, Event, Effect>
private let eventConsumerTransformer: ConsumerTransformer<Event>

fileprivate init<EffectHandler: Connectable>(
update: Update<Model, Event, Effect>,
effectHandler: EffectHandler,
eventSource: AnyEventSource<Event>,
eventSource: AnyConnectable<Model, Event>,
eventConsumerTransformer: @escaping ConsumerTransformer<Event>,
logger: AnyMobiusLogger<Model, Event, Effect>
) where EffectHandler.Input == Effect, EffectHandler.Output == Event {
Expand Down Expand Up @@ -140,7 +140,43 @@ public enum Mobius {
return Builder(
update: update,
effectHandler: effectHandler,
eventSource: AnyEventSource(eventSource),
eventSource: AnyConnectable { consumer in
var disposable: Disposable? = eventSource.subscribe(consumer: consumer)
return .init(
acceptClosure: { _ in },
disposeClosure: {
disposable?.dispose()
disposable = nil
}
)
},
eventConsumerTransformer: eventConsumerTransformer,
logger: logger
)
}

/// Return a copy of this builder with a new [event source] using a `Connectable<Model, Event>`.
///
/// If a `MobiusLoop` is created from the builder by calling `start`, the event source will be subscribed to
/// immediately, and the subscription will be disposed when the loop is disposed.
///
/// If a `MobiusController` is created by calling `makeController`, the controller will subscribe to the event
/// source each time `start` is called on the controller, and dispose the subscription when `stop` is called.
///
/// The loop will use the `Connectable<Model, Event>` event source, to invoke the `Connection<Model>`
/// accept method every time the model changes. This allows to conditionally subscribe to different sources based
/// on the current state
///
/// - Note: The event source will replace any existing event source.
///
/// - Parameter eventSource: The event source to set on the new builder.
/// - Returns: An updated Builder.
///
public func withEventSource<Source: Connectable>(_ eventSource: Source) -> Builder where Source.Input == Model, Source.Output == Event {
return Builder(
update: update,
effectHandler: effectHandler,
eventSource: AnyConnectable(eventSource),
eventConsumerTransformer: eventConsumerTransformer,
logger: logger
)
Expand Down
10 changes: 7 additions & 3 deletions MobiusCore/Source/MobiusLoop.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ public final class MobiusLoop<Model, Event, Effect>: Disposable {
private var workBag: WorkBag

private var effectConnection: Connection<Effect>! = nil
private var eventSourceConnection: Connection<Model>! = nil
private var consumeEvent: Consumer<Event>! = nil
private let modelPublisher: ConnectablePublisher<Model>

Expand All @@ -35,7 +36,7 @@ public final class MobiusLoop<Model, Event, Effect>: Disposable {
init(
model: Model,
update: Update<Model, Event, Effect>,
eventSource: AnyEventSource<Event>,
eventSource: AnyConnectable<Model, Event>,
eventConsumerTransformer: ConsumerTransformer<Event>,
effectHandler: AnyConnectable<Effect, Event>,
effects: [Effect],
Expand Down Expand Up @@ -72,12 +73,14 @@ public final class MobiusLoop<Model, Event, Effect>: Disposable {

// 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.eventSourceConnection = eventSource.connect { event in
consumeEvent(event)
}

self.disposable = CompositeDisposable(disposables: [
effectConnection,
modelPublisher,
eventSourceDisposable,
eventSourceConnection,
])

// Prime the modelPublisher, and queue up any initial effects.
Expand Down Expand Up @@ -154,6 +157,7 @@ public final class MobiusLoop<Model, Event, Effect>: Disposable {
private func processNext(_ next: Next<Model, Effect>) {
if let newModel = next.model {
model = newModel
eventSourceConnection.accept(model)
modelPublisher.post(model)
}

Expand Down
134 changes: 134 additions & 0 deletions MobiusCore/Test/InitializationTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
// Copyright 2019-2024 Spotify AB.
//
// Licensed 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 InitializationTests: QuickSpec {
// swiftlint:disable:next function_body_length
override func spec() {
describe("Initialization") {
var builder: Mobius.Builder<String, String, String>!
var updateFunction: Update<String, String, String>!
var loop: MobiusLoop<String, String, String>!
var receivedModels: [String]!
var modelObserver: Consumer<String>!
var effectHandler: RecordingTestConnectable!
var eventSource: TestEventSource<String>!
var connectableEventSource: TestConnectableEventSource<String, String>!

beforeEach {
receivedModels = []

modelObserver = { receivedModels.append($0) }

updateFunction = Update<String, String, String> { _, event in
if event == "event that triggers effect" {
return Next.next(event, effects: [event])
} else {
return Next.next(event)
}
}

effectHandler = RecordingTestConnectable()
eventSource = TestEventSource()
connectableEventSource = .init()

}

it("should process init") {
builder = Mobius.loop(update: updateFunction, effectHandler: effectHandler)

loop = builder.start(from: "the first model")

loop.addObserver(modelObserver)

expect(receivedModels).to(equal(["the first model"]))
}

it("should process init and then events") {
builder = Mobius.loop(update: updateFunction, effectHandler: effectHandler)

loop = builder.start(from: "the first model")

loop.addObserver(modelObserver)
loop.dispatchEvent("event that triggers effect")

expect(receivedModels).to(equal(["the first model", "event that triggers effect"]))
}

it("should process init before events from connectable event source") {
builder = Mobius.loop(update: updateFunction, effectHandler: effectHandler)
.withEventSource(connectableEventSource)

connectableEventSource.dispatch("ignored event from connectable event source")
loop = builder.start(from: "the first model")
loop.addObserver(modelObserver)

connectableEventSource.dispatch("second event from connectable event source")

// The first event was sent before the loop started so it should be ignored. The second should go through
expect(receivedModels).to(equal(["the first model", "second event from connectable event source"]))
}

it("should process init before events from event source") {
builder = Mobius.loop(update: updateFunction, effectHandler: effectHandler)
.withEventSource(eventSource)

eventSource.dispatch("ignored event from event source")
loop = builder.start(from: "the first model")
loop.addObserver(modelObserver)

eventSource.dispatch("second event from event source")

// The first event was sent before the loop started so it should be ignored. The second should go through
expect(receivedModels).to(equal(["the first model", "second event from event source"]))
}
}
}
}

// Emits values before returning the connection
class EagerTestConnectable: Connectable {
private(set) var consumer: Consumer<String>?
private(set) var recorder: Recorder<String>
private(set) var eagerValue: String

private(set) var connection: Connection<String>!

init(eagerValue: String) {
self.recorder = Recorder()
self.eagerValue = eagerValue
}

func connect(_ consumer: @escaping (String) -> Void) -> Connection<String> {
self.consumer = consumer
connection = Connection(acceptClosure: accept, disposeClosure: dispose) // Will retain self
connection.accept(eagerValue) // emit before returning
return connection
}

func dispatch(_ string: String) {
consumer?(string)
}

func accept(_ value: String) {
recorder.append(value)
}

func dispose() {
}
}
69 changes: 67 additions & 2 deletions MobiusCore/Test/MobiusControllerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,11 @@ class MobiusControllerTests: QuickSpec {
override func spec() {
describe("MobiusController") {
var controller: MobiusController<String, String, String>!
var updateFunction: Update<String, String, String>!
var initiate: Initiate<String, String>!
var view: RecordingTestConnectable!
var eventSource: TestEventSource<String>!
var connectableEventSource: TestConnectableEventSource<String, String>!
var effectHandler: RecordingTestConnectable!
var activateInitiator: Bool!

Expand All @@ -42,13 +45,13 @@ class MobiusControllerTests: QuickSpec {
view = RecordingTestConnectable(expectedQueue: self.viewQueue)
let loopQueue = self.loopQueue

let updateFunction = Update<String, String, String> { model, event in
updateFunction = .init { model, event in
dispatchPrecondition(condition: .onQueue(loopQueue))
return .next("\(model)-\(event)")
}

activateInitiator = false
let initiate: Initiate<String, String> = { model in
initiate = .init { model in
if activateInitiator {
return First(model: "\(model)-init", effects: ["initEffect"])
} else {
Expand All @@ -57,6 +60,7 @@ class MobiusControllerTests: QuickSpec {
}

eventSource = TestEventSource()

effectHandler = RecordingTestConnectable()

controller = Mobius.loop(update: updateFunction, effectHandler: effectHandler)
Expand Down Expand Up @@ -356,6 +360,67 @@ class MobiusControllerTests: QuickSpec {
}
}

describe("dispatching events using a connectable") {
beforeEach {
// Rebuild the controller but use the Connectable instead of plain EventSource
connectableEventSource = .init()

controller = Mobius.loop(update: updateFunction, effectHandler: effectHandler)
.withEventSource(connectableEventSource)
.makeController(
from: "S",
initiate: initiate,
loopQueue: self.loopQueue,
viewQueue: self.viewQueue
)
controller.connectView(view)
controller.start()
}

it("should dispatch events from the event source") {
connectableEventSource.dispatch("event source event")

expect(view.recorder.items).toEventually(equal(["S", "S-event source event"]))
}

it("should receive models from the event source") {
view.dispatch("new model")
expect(connectableEventSource.models).toEventually(equal(["S", "S-new model"]))
}

it("should allow the event source to change with model updates") {
connectableEventSource.shouldProcessModel = { model in
model != "S-ignore"
}

view.dispatch("ignore")
view.dispatch("new model 2")
expect(connectableEventSource.models).toEventually(equal(["S", "S-ignore-new model 2"]))
}

it("should replace the event source") {
connectableEventSource = .init()

controller = Mobius.loop(update: updateFunction, effectHandler: effectHandler)
.withEventSource(eventSource)
.withEventSource(connectableEventSource)
.makeController(
from: "S",
initiate: initiate,
loopQueue: self.loopQueue,
viewQueue: self.viewQueue
)
controller.connectView(view)
controller.start()

eventSource.dispatch("event source event")
connectableEventSource.dispatch("connectable event source event")

// The connectable event source should have replaced the original normal event source
expect(connectableEventSource.models).toEventually(equal(["S", "S-connectable event source event"]))
}
}

describe("deallocating") {
var modelObserver: MockConsumerConnectable!
var effectObserver: MockConnectable!
Expand Down
Loading