From 6f680f453c788615f410b5cdde61d13667ee7450 Mon Sep 17 00:00:00 2001 From: Jens Ayton Date: Mon, 27 May 2019 11:50:03 +0200 Subject: [PATCH 1/2] Add CompositeEventSourceBuilder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We currently have MergedEventSource to compose multiple event sources with the same event type, but it’s unergonomic because it takes an array of event sources, but you can’t construct an array of heterogeneous event sources due to limitations on protocols with associated types. The current workaround is to explicitly wrap the members of the array in AnyEventSource, but explicit use of type erasure in clients is unpleasant. Using a builder lets us take each individual event source as a generic parameter and handle the type erasure internally. I called it CompositeEventSourceBuilder rather than MergedEventSource by analogy to CompositeDisposable. --- Mobius.xcodeproj/project.pbxproj | 6 ++ .../CompositeEventSourceBuilder.swift | 62 +++++++++++++++++++ .../EventSources/MergedEventSource.swift | 1 + 3 files changed, 69 insertions(+) create mode 100644 MobiusCore/Source/EventSources/CompositeEventSourceBuilder.swift diff --git a/Mobius.xcodeproj/project.pbxproj b/Mobius.xcodeproj/project.pbxproj index a9c0c549..22b0fdd5 100644 --- a/Mobius.xcodeproj/project.pbxproj +++ b/Mobius.xcodeproj/project.pbxproj @@ -10,6 +10,8 @@ 02BED1BB21DD20D20093FB47 /* ConnectableContramap.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B9CE80421197FE000DB79A7 /* ConnectableContramap.swift */; }; 2D54D0F021C11362002AAC19 /* AtomicBool.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D54D0EF21C11362002AAC19 /* AtomicBool.swift */; }; 2D54D0F121C1167C002AAC19 /* AtomicBool.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D54D0EF21C11362002AAC19 /* AtomicBool.swift */; }; + 2DDF54C0229BDB4800D05861 /* CompositeEventSourceBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DDF54BF229BDB4700D05861 /* CompositeEventSourceBuilder.swift */; }; + 2DDF54C1229BDB4800D05861 /* CompositeEventSourceBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DDF54BF229BDB4700D05861 /* CompositeEventSourceBuilder.swift */; }; 2DF4C2FC20DBDD5800A4B6DE /* Next.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB287B5209995410043B530 /* Next.swift */; }; 2DF4C2FD20DBDD5800A4B6DE /* Connection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB287B6209995410043B530 /* Connection.swift */; }; 2DF4C2FE20DBDD5800A4B6DE /* Connectable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B237EB9209C4F3C00764576 /* Connectable.swift */; }; @@ -258,6 +260,7 @@ /* Begin PBXFileReference section */ 2D2FE60F20625E76002DFD69 /* Mobius.playground */ = {isa = PBXFileReference; lastKnownFileType = file.playground; path = Mobius.playground; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; 2D54D0EF21C11362002AAC19 /* AtomicBool.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AtomicBool.swift; sourceTree = ""; }; + 2DDF54BF229BDB4700D05861 /* CompositeEventSourceBuilder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CompositeEventSourceBuilder.swift; sourceTree = ""; }; 2DF4C2F520DBDD4700A4B6DE /* libMobiusCore.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libMobiusCore.a; sourceTree = BUILT_PRODUCTS_DIR; }; 2DF4C41D20DBDEFA00A4B6DE /* libMobiusExtras.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libMobiusExtras.a; sourceTree = BUILT_PRODUCTS_DIR; }; 2DF4C53320DBE03900A4B6DE /* libMobiusTest.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libMobiusTest.a; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -547,6 +550,7 @@ isa = PBXGroup; children = ( 5B4A369921107D2600279C7D /* AnyEventSource.swift */, + 2DDF54BF229BDB4700D05861 /* CompositeEventSourceBuilder.swift */, 5BB287C0209995410043B530 /* EventSource.swift */, 5B4A36952110783100279C7D /* MergedEventSource.swift */, ); @@ -1198,6 +1202,7 @@ 2DF4C30720DBDD5C00A4B6DE /* EventSource.swift in Sources */, 2DF4C2FD20DBDD5800A4B6DE /* Connection.swift in Sources */, 5B7095992109E89C0099298B /* EffectRouterBuilder.swift in Sources */, + 2DDF54C1229BDB4800D05861 /* CompositeEventSourceBuilder.swift in Sources */, 5B1F1040210F5EE40067193C /* ConsumerConnectable.swift in Sources */, 5B1F1044210F5F590067193C /* ClosureConnectable.swift in Sources */, 2DF4C2FE20DBDD5800A4B6DE /* Connectable.swift in Sources */, @@ -1256,6 +1261,7 @@ 5BB288172099957D0043B530 /* Next.swift in Sources */, 5BB28827209995810043B530 /* MobiusLogger.swift in Sources */, 5BB28823209995810043B530 /* NoEffect.swift in Sources */, + 2DDF54C0229BDB4800D05861 /* CompositeEventSourceBuilder.swift in Sources */, 5B1F103F210F5EE40067193C /* ConsumerConnectable.swift in Sources */, 5B1F1043210F5F590067193C /* ClosureConnectable.swift in Sources */, 5BB288182099957D0043B530 /* Connection.swift in Sources */, diff --git a/MobiusCore/Source/EventSources/CompositeEventSourceBuilder.swift b/MobiusCore/Source/EventSources/CompositeEventSourceBuilder.swift new file mode 100644 index 00000000..ef366077 --- /dev/null +++ b/MobiusCore/Source/EventSources/CompositeEventSourceBuilder.swift @@ -0,0 +1,62 @@ +// Copyright (c) 2019 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. + +/// A `CompositeEventSourceBuilder` gathers the provided event sources together and builds a single event source that +/// subscribes to all of them when its `subscribe` method is called. +public struct CompositeEventSourceBuilder { + private let eventSources: [AnyEventSource] + + /// Initializes a `CompositeEventSourceBuilder`. + public init() { + self.init(eventSources: []) + } + + private init(eventSources: [AnyEventSource]) { + self.eventSources = eventSources + } + + /// Returns a new `CompositeEventSourceBuilder` with the specified event source added to it. + public func addEventSource(_ source: Source) + -> CompositeEventSourceBuilder where Source.Event == Event { + let es = AnyEventSource(source) + let sources = eventSources + [es] + return CompositeEventSourceBuilder(eventSources: sources) + } + + /// Builds an event source that composes all the event sources that have been added to the builder. + /// + /// - Returns: An event source which represents the composition of the builder’s input event sources. The type + /// of this source is an implementation detail; consumers should avoid spelling it out if possible. + public func build() -> AnyEventSource { + switch eventSources.count { + case 0: + return AnyEventSource { _ in AnonymousDisposable {} } + case 1: + return eventSources[0] + default: + let eventSources = self.eventSources + return AnyEventSource { consumer in + let disposables = eventSources.map { + $0.subscribe(consumer: consumer) + } + return CompositeDisposable(disposables: disposables) + } + } + } +} diff --git a/MobiusCore/Source/EventSources/MergedEventSource.swift b/MobiusCore/Source/EventSources/MergedEventSource.swift index f57f7082..db8fb0b4 100644 --- a/MobiusCore/Source/EventSources/MergedEventSource.swift +++ b/MobiusCore/Source/EventSources/MergedEventSource.swift @@ -21,6 +21,7 @@ import Foundation /// A `MergedEventSource` holds onto the provided event sources and subscribes consumers to all of them once its /// `subscribe` method is called. +@available(*, deprecated, message: "use CompositeEventSourceBuilder instead") public final class MergedEventSource: EventSource { private let eventSources: [AnyEventSource] From 7fb96ff5fb386299b8b83ffb2300b199aa23fac1 Mon Sep 17 00:00:00 2001 From: Jens Ayton Date: Mon, 27 May 2019 13:43:35 +0200 Subject: [PATCH 2/2] Add unit tests for CompositeEventSourceBuilder --- Mobius.xcodeproj/project.pbxproj | 6 + .../CompositeEventSourceBuilder.swift | 3 +- .../CompositeEventSourceBuilderTests.swift | 139 ++++++++++++++++++ 3 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 MobiusCore/Test/EventSources/CompositeEventSourceBuilderTests.swift diff --git a/Mobius.xcodeproj/project.pbxproj b/Mobius.xcodeproj/project.pbxproj index 22b0fdd5..6916cdaa 100644 --- a/Mobius.xcodeproj/project.pbxproj +++ b/Mobius.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ 2D54D0F121C1167C002AAC19 /* AtomicBool.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D54D0EF21C11362002AAC19 /* AtomicBool.swift */; }; 2DDF54C0229BDB4800D05861 /* CompositeEventSourceBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DDF54BF229BDB4700D05861 /* CompositeEventSourceBuilder.swift */; }; 2DDF54C1229BDB4800D05861 /* CompositeEventSourceBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DDF54BF229BDB4700D05861 /* CompositeEventSourceBuilder.swift */; }; + 2DDF54C3229BEEC400D05861 /* CompositeEventSourceBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DDF54C2229BEEC400D05861 /* CompositeEventSourceBuilderTests.swift */; }; 2DF4C2FC20DBDD5800A4B6DE /* Next.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB287B5209995410043B530 /* Next.swift */; }; 2DF4C2FD20DBDD5800A4B6DE /* Connection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB287B6209995410043B530 /* Connection.swift */; }; 2DF4C2FE20DBDD5800A4B6DE /* Connectable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B237EB9209C4F3C00764576 /* Connectable.swift */; }; @@ -261,6 +262,7 @@ 2D2FE60F20625E76002DFD69 /* Mobius.playground */ = {isa = PBXFileReference; lastKnownFileType = file.playground; path = Mobius.playground; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; 2D54D0EF21C11362002AAC19 /* AtomicBool.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AtomicBool.swift; sourceTree = ""; }; 2DDF54BF229BDB4700D05861 /* CompositeEventSourceBuilder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CompositeEventSourceBuilder.swift; sourceTree = ""; }; + 2DDF54C2229BEEC400D05861 /* CompositeEventSourceBuilderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositeEventSourceBuilderTests.swift; sourceTree = ""; }; 2DF4C2F520DBDD4700A4B6DE /* libMobiusCore.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libMobiusCore.a; sourceTree = BUILT_PRODUCTS_DIR; }; 2DF4C41D20DBDEFA00A4B6DE /* libMobiusExtras.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libMobiusExtras.a; sourceTree = BUILT_PRODUCTS_DIR; }; 2DF4C53320DBE03900A4B6DE /* libMobiusTest.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libMobiusTest.a; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -561,6 +563,7 @@ isa = PBXGroup; children = ( 5BB2885320999ACE0043B530 /* AnyEventSourceTests.swift */, + 2DDF54C2229BEEC400D05861 /* CompositeEventSourceBuilderTests.swift */, 5B4A369D21107F3200279C7D /* MergedEventSourceTests.swift */, ); path = EventSources; @@ -1290,6 +1293,7 @@ 5BB2887820999AD60043B530 /* AnyConnectionTests.swift in Sources */, 5B1F104B211037500067193C /* ConsumerConnectableTests.swift in Sources */, 5BB2887120999AD60043B530 /* LoggingInitTests.swift in Sources */, + 2DDF54C3229BEEC400D05861 /* CompositeEventSourceBuilderTests.swift in Sources */, 5B85AD0220AAA8CA00C4FCD5 /* MobiusHooksTests.swift in Sources */, 5BB2886D20999AD60043B530 /* MobiusControllerTests.swift in Sources */, 5BB2887B20999AD60043B530 /* ConnectablePublisherTests.swift in Sources */, @@ -1558,6 +1562,7 @@ LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks $(PROJECT_DIR)/Carthage/Build/iOS"; PRODUCT_BUNDLE_IDENTIFIER = com.spotify.MobiusCoreTests; PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_TREAT_WARNINGS_AS_ERRORS = NO; }; name = Debug; }; @@ -1572,6 +1577,7 @@ LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks $(PROJECT_DIR)/Carthage/Build/iOS"; PRODUCT_BUNDLE_IDENTIFIER = com.spotify.MobiusCoreTests; PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_TREAT_WARNINGS_AS_ERRORS = NO; }; name = Release; }; diff --git a/MobiusCore/Source/EventSources/CompositeEventSourceBuilder.swift b/MobiusCore/Source/EventSources/CompositeEventSourceBuilder.swift index ef366077..73d28dbd 100644 --- a/MobiusCore/Source/EventSources/CompositeEventSourceBuilder.swift +++ b/MobiusCore/Source/EventSources/CompositeEventSourceBuilder.swift @@ -34,8 +34,7 @@ public struct CompositeEventSourceBuilder { /// Returns a new `CompositeEventSourceBuilder` with the specified event source added to it. public func addEventSource(_ source: Source) -> CompositeEventSourceBuilder where Source.Event == Event { - let es = AnyEventSource(source) - let sources = eventSources + [es] + let sources = eventSources + [AnyEventSource(source)] return CompositeEventSourceBuilder(eventSources: sources) } diff --git a/MobiusCore/Test/EventSources/CompositeEventSourceBuilderTests.swift b/MobiusCore/Test/EventSources/CompositeEventSourceBuilderTests.swift new file mode 100644 index 00000000..012d5e7a --- /dev/null +++ b/MobiusCore/Test/EventSources/CompositeEventSourceBuilderTests.swift @@ -0,0 +1,139 @@ +// Copyright (c) 2019 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. + +@testable import MobiusCore +import Nimble +import Quick + +class CompositeEventSourceBuilderTest: QuickSpec { + // swiftlint:disable function_body_length + override func spec() { + var eventsReceived: [Int]! + var compositeEventSource: AnyEventSource! + var disposable: Disposable! + + describe("CompositeEventSourceBuilder") { + context("when configuring the composite event source builder") { + context("with no event sources") { + beforeEach { + let sut = CompositeEventSourceBuilder() + compositeEventSource = sut.build() + eventsReceived = [] + } + + it("should produce an event source") { + // In particular, we want a do-nothing event source rather than an assertion or crash. + disposable = compositeEventSource.subscribe { + eventsReceived.append($0) + } + disposable.dispose() + + expect(eventsReceived).to(equal([])) + } + } + + context("with one event source") { + var eventSource: TestEventSource! + + beforeEach { + eventSource = TestEventSource() + let sut = CompositeEventSourceBuilder() + .addEventSource(eventSource) + + compositeEventSource = sut.build() + eventsReceived = [] + + disposable = compositeEventSource.subscribe { + eventsReceived.append($0) + } + } + + it("should provide an event source equivalent to the input event source") { + eventSource.dispatch(1) + eventSource.dispatch(2) + + let expectedEvents = [1, 2] + expect(eventsReceived).to(equal(expectedEvents)) + } + + it("should return a disposable that disposes the original event source") { + disposable?.dispose() + + expect(eventSource.isDisposed).to(beTrue()) + } + } + + context("with several event sources") { + var eventSources: [TestEventSource]! + + beforeEach { + eventSources = [TestEventSource(), TestEventSource(), TestEventSource()] + var sut = CompositeEventSourceBuilder() + eventSources.forEach { + sut = sut.addEventSource($0) + } + + compositeEventSource = sut.build() + eventsReceived = [] + + disposable = compositeEventSource.subscribe { + eventsReceived.append($0) + } + } + + it("should produce an event source that emits the events from all input sources") { + eventSources.enumerated().forEach { index, source in + source.dispatch(index) + } + + let expectedEvents = [0, 1, 2] + expect(eventsReceived).to(equal(expectedEvents)) + } + + it("should return a disposable that disposes of all the input event sources") { + disposable?.dispose() + + eventSources.forEach { + expect($0.isDisposed).to(beTrue()) + } + } + } + } + } + } +} + +private class TestEventSource: EventSource, Disposable { + typealias Event = Int + + var consumer: Consumer? + func subscribe(consumer: @escaping Consumer) -> Disposable { + self.consumer = consumer + return self + } + + var isDisposed = false + func dispose() { + isDisposed = true + } + + func dispatch(_ event: Int) { + consumer?(event) + } +}