Skip to content

Commit

Permalink
Prototype: making EventSource a Connectable<M, E>
Browse files Browse the repository at this point in the history
  • Loading branch information
dalef84 committed Dec 10, 2024
1 parent 315a69a commit fc261ce
Show file tree
Hide file tree
Showing 4 changed files with 131 additions and 9 deletions.
36 changes: 32 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,35 @@ 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 // EventSource ignores the Model (value)
}, disposeClosure: {
disposable?.dispose()
disposable = nil
})
},
eventConsumerTransformer: eventConsumerTransformer,
logger: logger
)
}

/// TODO
/*
@return a new {@link Builder} with the supplied {@link Connectable<M,E>}, and the same values
as the current one for the other fields. NOTE: Invoking this method will replace the
current event source with the supplied one. If a loop has a {@link Connectable<M,E>} as
its event source, it will connect to it and will invoke the {@link Connection<M>} accept
method every time the model changes. This allows us to conditionally subscribe to
different sources based on the current state. If you provide a regular {@link
EventSource<E>}, it will be wrapped in a {@link Connectable} and that implementation will
subscribe to that event source only once when the loop is initialized.
*/
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) // TODO what about the model??
}

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
37 changes: 35 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,35 @@ class MobiusControllerTests: QuickSpec {
}
}

describe("dispatching events using 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"]))
}
}

describe("deallocating") {
var modelObserver: MockConsumerConnectable!
var effectObserver: MockConnectable!
Expand Down
57 changes: 57 additions & 0 deletions MobiusCore/Test/TestingUtil.swift
Original file line number Diff line number Diff line change
Expand Up @@ -193,3 +193,60 @@ class TestEventSource<Event>: EventSource {
}
}
}

class TestConnectableEventSource<Model, Event>: Connectable {
typealias Input = Model
typealias Output = Event

enum Connection {
case disposed
case active(Consumer<Event>)
}
private(set) var connections: [Connection] = []
private(set) var models: [Model] = []
private var pendingEvent: Event?

var activeConnections: [Consumer<Event>] {
return connections.compactMap {
switch $0 {
case .disposed:
return nil
case .active(let consumer):
return consumer
}
}
}

var allDisposed: Bool {
return activeConnections.isEmpty
}

func connect(_ consumer: @escaping MobiusCore.Consumer<Event>) -> MobiusCore.Connection<Model> {
let index = connections.count
connections.append(.active(consumer))

if let event = pendingEvent {
consumer(event)
pendingEvent = nil
}

return .init(
acceptClosure: { [weak self] model in
self?.models.append(model)
}, disposeClosure: { [weak self] in
self?.connections[index] = .disposed
}
)
}

// Set an event to dispatch immediately when subscribed
func dispatchOnSubscribe(_ event: Event) {
pendingEvent = event
}

func dispatch(_ event: Event) {
activeConnections.forEach {
$0(event)
}
}
}

0 comments on commit fc261ce

Please sign in to comment.