Skip to content

Commit 8bc883f

Browse files
Merge pull request #211 from dalef84/eventsource-connectable
EventSource as Connectable
2 parents a09aa60 + 97c2d2a commit 8bc883f

File tree

5 files changed

+313
-9
lines changed

5 files changed

+313
-9
lines changed

MobiusCore/Source/Mobius.swift

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ public enum Mobius {
6363
return Builder(
6464
update: update,
6565
effectHandler: effectHandler,
66-
eventSource: AnyEventSource({ _ in AnonymousDisposable(disposer: {}) }),
66+
eventSource: AnyConnectable { _ in .init(acceptClosure: { _ in }, disposeClosure: {}) },
6767
eventConsumerTransformer: { $0 },
6868
logger: AnyMobiusLogger(NoopLogger())
6969
)
@@ -93,14 +93,14 @@ public enum Mobius {
9393
public struct Builder<Model, Event, Effect> {
9494
private let update: Update<Model, Event, Effect>
9595
private let effectHandler: AnyConnectable<Effect, Event>
96-
private let eventSource: AnyEventSource<Event>
96+
private let eventSource: AnyConnectable<Model, Event>
9797
private let logger: AnyMobiusLogger<Model, Event, Effect>
9898
private let eventConsumerTransformer: ConsumerTransformer<Event>
9999

100100
fileprivate init<EffectHandler: Connectable>(
101101
update: Update<Model, Event, Effect>,
102102
effectHandler: EffectHandler,
103-
eventSource: AnyEventSource<Event>,
103+
eventSource: AnyConnectable<Model, Event>,
104104
eventConsumerTransformer: @escaping ConsumerTransformer<Event>,
105105
logger: AnyMobiusLogger<Model, Event, Effect>
106106
) where EffectHandler.Input == Effect, EffectHandler.Output == Event {
@@ -129,7 +129,43 @@ public enum Mobius {
129129
return Builder(
130130
update: update,
131131
effectHandler: effectHandler,
132-
eventSource: AnyEventSource(eventSource),
132+
eventSource: AnyConnectable { consumer in
133+
var disposable: Disposable? = eventSource.subscribe(consumer: consumer)
134+
return .init(
135+
acceptClosure: { _ in },
136+
disposeClosure: {
137+
disposable?.dispose()
138+
disposable = nil
139+
}
140+
)
141+
},
142+
eventConsumerTransformer: eventConsumerTransformer,
143+
logger: logger
144+
)
145+
}
146+
147+
/// Return a copy of this builder with a new [event source] using a `Connectable<Model, Event>`.
148+
///
149+
/// If a `MobiusLoop` is created from the builder by calling `start`, the event source will be subscribed to
150+
/// immediately, and the subscription will be disposed when the loop is disposed.
151+
///
152+
/// If a `MobiusController` is created by calling `makeController`, the controller will subscribe to the event
153+
/// source each time `start` is called on the controller, and dispose the subscription when `stop` is called.
154+
///
155+
/// The loop will use the `Connectable<Model, Event>` event source, to invoke the `Connection<Model>`
156+
/// accept method every time the model changes. This allows to conditionally subscribe to different sources based
157+
/// on the current state
158+
///
159+
/// - Note: The event source will replace any existing event source.
160+
///
161+
/// - Parameter eventSource: The event source to set on the new builder.
162+
/// - Returns: An updated Builder.
163+
///
164+
public func withEventSource<Source: Connectable>(_ eventSource: Source) -> Builder where Source.Input == Model, Source.Output == Event {
165+
return Builder(
166+
update: update,
167+
effectHandler: effectHandler,
168+
eventSource: AnyConnectable(eventSource),
133169
eventConsumerTransformer: eventConsumerTransformer,
134170
logger: logger
135171
)

MobiusCore/Source/MobiusLoop.swift

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ public final class MobiusLoop<Model, Event, Effect>: Disposable {
1414
private var workBag: WorkBag
1515

1616
private var effectConnection: Connection<Effect>! = nil
17+
private var eventSourceConnection: Connection<Model>! = nil
1718
private var consumeEvent: Consumer<Event>! = nil
1819
private let modelPublisher: ConnectablePublisher<Model>
1920

@@ -24,7 +25,7 @@ public final class MobiusLoop<Model, Event, Effect>: Disposable {
2425
init(
2526
model: Model,
2627
update: Update<Model, Event, Effect>,
27-
eventSource: AnyEventSource<Event>,
28+
eventSource: AnyConnectable<Model, Event>,
2829
eventConsumerTransformer: ConsumerTransformer<Event>,
2930
effectHandler: AnyConnectable<Effect, Event>,
3031
effects: [Effect],
@@ -61,12 +62,14 @@ public final class MobiusLoop<Model, Event, Effect>: Disposable {
6162

6263
// These must be set up after consumeEvent, which refers to self; that’s why they need to be IUOs.
6364
self.effectConnection = effectHandler.connect(consumeEvent)
64-
let eventSourceDisposable = eventSource.subscribe(consumer: consumeEvent)
65+
self.eventSourceConnection = eventSource.connect { event in
66+
consumeEvent(event)
67+
}
6568

6669
self.disposable = CompositeDisposable(disposables: [
6770
effectConnection,
6871
modelPublisher,
69-
eventSourceDisposable,
72+
eventSourceConnection,
7073
])
7174

7275
// Prime the modelPublisher, and queue up any initial effects.
@@ -143,6 +146,7 @@ public final class MobiusLoop<Model, Event, Effect>: Disposable {
143146
private func processNext(_ next: Next<Model, Effect>) {
144147
if let newModel = next.model {
145148
model = newModel
149+
eventSourceConnection.accept(model)
146150
modelPublisher.post(model)
147151
}
148152

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
// Copyright 2019-2024 Spotify AB.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import Foundation
16+
@testable import MobiusCore
17+
import Nimble
18+
import Quick
19+
20+
class InitializationTests: QuickSpec {
21+
// swiftlint:disable:next function_body_length
22+
override func spec() {
23+
describe("Initialization") {
24+
var builder: Mobius.Builder<String, String, String>!
25+
var updateFunction: Update<String, String, String>!
26+
var loop: MobiusLoop<String, String, String>!
27+
var receivedModels: [String]!
28+
var modelObserver: Consumer<String>!
29+
var effectHandler: RecordingTestConnectable!
30+
var eventSource: TestEventSource<String>!
31+
var connectableEventSource: TestConnectableEventSource<String, String>!
32+
33+
beforeEach {
34+
receivedModels = []
35+
36+
modelObserver = { receivedModels.append($0) }
37+
38+
updateFunction = Update<String, String, String> { _, event in
39+
if event == "event that triggers effect" {
40+
return Next.next(event, effects: [event])
41+
} else {
42+
return Next.next(event)
43+
}
44+
}
45+
46+
effectHandler = RecordingTestConnectable()
47+
eventSource = TestEventSource()
48+
connectableEventSource = .init()
49+
50+
}
51+
52+
it("should process init") {
53+
builder = Mobius.loop(update: updateFunction, effectHandler: effectHandler)
54+
55+
loop = builder.start(from: "the first model")
56+
57+
loop.addObserver(modelObserver)
58+
59+
expect(receivedModels).to(equal(["the first model"]))
60+
}
61+
62+
it("should process init and then events") {
63+
builder = Mobius.loop(update: updateFunction, effectHandler: effectHandler)
64+
65+
loop = builder.start(from: "the first model")
66+
67+
loop.addObserver(modelObserver)
68+
loop.dispatchEvent("event that triggers effect")
69+
70+
expect(receivedModels).to(equal(["the first model", "event that triggers effect"]))
71+
}
72+
73+
it("should process init before events from connectable event source") {
74+
builder = Mobius.loop(update: updateFunction, effectHandler: effectHandler)
75+
.withEventSource(connectableEventSource)
76+
77+
connectableEventSource.dispatch("ignored event from connectable event source")
78+
loop = builder.start(from: "the first model")
79+
loop.addObserver(modelObserver)
80+
81+
connectableEventSource.dispatch("second event from connectable event source")
82+
83+
// The first event was sent before the loop started so it should be ignored. The second should go through
84+
expect(receivedModels).to(equal(["the first model", "second event from connectable event source"]))
85+
}
86+
87+
it("should process init before events from event source") {
88+
builder = Mobius.loop(update: updateFunction, effectHandler: effectHandler)
89+
.withEventSource(eventSource)
90+
91+
eventSource.dispatch("ignored event from event source")
92+
loop = builder.start(from: "the first model")
93+
loop.addObserver(modelObserver)
94+
95+
eventSource.dispatch("second event from event source")
96+
97+
// The first event was sent before the loop started so it should be ignored. The second should go through
98+
expect(receivedModels).to(equal(["the first model", "second event from event source"]))
99+
}
100+
}
101+
}
102+
}
103+
104+
// Emits values before returning the connection
105+
class EagerTestConnectable: Connectable {
106+
private(set) var consumer: Consumer<String>?
107+
private(set) var recorder: Recorder<String>
108+
private(set) var eagerValue: String
109+
110+
private(set) var connection: Connection<String>!
111+
112+
init(eagerValue: String) {
113+
self.recorder = Recorder()
114+
self.eagerValue = eagerValue
115+
}
116+
117+
func connect(_ consumer: @escaping (String) -> Void) -> Connection<String> {
118+
self.consumer = consumer
119+
connection = Connection(acceptClosure: accept, disposeClosure: dispose) // Will retain self
120+
connection.accept(eagerValue) // emit before returning
121+
return connection
122+
}
123+
124+
func dispatch(_ string: String) {
125+
consumer?(string)
126+
}
127+
128+
func accept(_ value: String) {
129+
recorder.append(value)
130+
}
131+
132+
func dispose() {
133+
}
134+
}

MobiusCore/Test/MobiusControllerTests.swift

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,11 @@ class MobiusControllerTests: QuickSpec {
1717
override func spec() {
1818
describe("MobiusController") {
1919
var controller: MobiusController<String, String, String>!
20+
var updateFunction: Update<String, String, String>!
21+
var initiate: Initiate<String, String>!
2022
var view: RecordingTestConnectable!
2123
var eventSource: TestEventSource<String>!
24+
var connectableEventSource: TestConnectableEventSource<String, String>!
2225
var effectHandler: RecordingTestConnectable!
2326
var activateInitiator: Bool!
2427

@@ -31,13 +34,13 @@ class MobiusControllerTests: QuickSpec {
3134
view = RecordingTestConnectable(expectedQueue: self.viewQueue)
3235
let loopQueue = self.loopQueue
3336

34-
let updateFunction = Update<String, String, String> { model, event in
37+
updateFunction = .init { model, event in
3538
dispatchPrecondition(condition: .onQueue(loopQueue))
3639
return .next("\(model)-\(event)")
3740
}
3841

3942
activateInitiator = false
40-
let initiate: Initiate<String, String> = { model in
43+
initiate = .init { model in
4144
if activateInitiator {
4245
return First(model: "\(model)-init", effects: ["initEffect"])
4346
} else {
@@ -46,6 +49,7 @@ class MobiusControllerTests: QuickSpec {
4649
}
4750

4851
eventSource = TestEventSource()
52+
4953
effectHandler = RecordingTestConnectable()
5054

5155
controller = Mobius.loop(update: updateFunction, effectHandler: effectHandler)
@@ -345,6 +349,67 @@ class MobiusControllerTests: QuickSpec {
345349
}
346350
}
347351

352+
describe("dispatching events using a connectable") {
353+
beforeEach {
354+
// Rebuild the controller but use the Connectable instead of plain EventSource
355+
connectableEventSource = .init()
356+
357+
controller = Mobius.loop(update: updateFunction, effectHandler: effectHandler)
358+
.withEventSource(connectableEventSource)
359+
.makeController(
360+
from: "S",
361+
initiate: initiate,
362+
loopQueue: self.loopQueue,
363+
viewQueue: self.viewQueue
364+
)
365+
controller.connectView(view)
366+
controller.start()
367+
}
368+
369+
it("should dispatch events from the event source") {
370+
connectableEventSource.dispatch("event source event")
371+
372+
expect(view.recorder.items).toEventually(equal(["S", "S-event source event"]))
373+
}
374+
375+
it("should receive models from the event source") {
376+
view.dispatch("new model")
377+
expect(connectableEventSource.models).toEventually(equal(["S", "S-new model"]))
378+
}
379+
380+
it("should allow the event source to change with model updates") {
381+
connectableEventSource.shouldProcessModel = { model in
382+
model != "S-ignore"
383+
}
384+
385+
view.dispatch("ignore")
386+
view.dispatch("new model 2")
387+
expect(connectableEventSource.models).toEventually(equal(["S", "S-ignore-new model 2"]))
388+
}
389+
390+
it("should replace the event source") {
391+
connectableEventSource = .init()
392+
393+
controller = Mobius.loop(update: updateFunction, effectHandler: effectHandler)
394+
.withEventSource(eventSource)
395+
.withEventSource(connectableEventSource)
396+
.makeController(
397+
from: "S",
398+
initiate: initiate,
399+
loopQueue: self.loopQueue,
400+
viewQueue: self.viewQueue
401+
)
402+
controller.connectView(view)
403+
controller.start()
404+
405+
eventSource.dispatch("event source event")
406+
connectableEventSource.dispatch("connectable event source event")
407+
408+
// The connectable event source should have replaced the original normal event source
409+
expect(connectableEventSource.models).toEventually(equal(["S", "S-connectable event source event"]))
410+
}
411+
}
412+
348413
describe("deallocating") {
349414
var modelObserver: MockConsumerConnectable!
350415
var effectObserver: MockConnectable!

0 commit comments

Comments
 (0)