From 850e1f3639e226f45f86575dd490663418b31e46 Mon Sep 17 00:00:00 2001 From: Gordon Smith Date: Mon, 26 Jul 2021 18:41:18 +0100 Subject: [PATCH 01/28] test: on init emits initial state --- Yolo.xcodeproj/project.pbxproj | 12 +++++++++ YoloTests/Helpers/StateContainerTests.swift | 30 +++++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 YoloTests/Helpers/StateContainerTests.swift diff --git a/Yolo.xcodeproj/project.pbxproj b/Yolo.xcodeproj/project.pbxproj index 0c020a5..326ed1b 100644 --- a/Yolo.xcodeproj/project.pbxproj +++ b/Yolo.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 6A08AA7026AF29CA00BA287C /* StateContainerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A08AA6F26AF29CA00BA287C /* StateContainerTests.swift */; }; 6A137528263BEB4D003F0E5D /* Content.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A137527263BEB4D003F0E5D /* Content.swift */; }; 6A13752F263BEC40003F0E5D /* ContentResponseMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A13752E263BEC40003F0E5D /* ContentResponseMapper.swift */; }; 6A137537263BED60003F0E5D /* ContentResponseMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A137536263BED60003F0E5D /* ContentResponseMapperTests.swift */; }; @@ -101,6 +102,7 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 6A08AA6F26AF29CA00BA287C /* StateContainerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StateContainerTests.swift; sourceTree = ""; }; 6A137527263BEB4D003F0E5D /* Content.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Content.swift; sourceTree = ""; }; 6A13752E263BEC40003F0E5D /* ContentResponseMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentResponseMapper.swift; sourceTree = ""; }; 6A137536263BED60003F0E5D /* ContentResponseMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentResponseMapperTests.swift; sourceTree = ""; }; @@ -206,6 +208,14 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 6A08AA6E26AF29B800BA287C /* Helpers */ = { + isa = PBXGroup; + children = ( + 6A08AA6F26AF29CA00BA287C /* StateContainerTests.swift */, + ); + path = Helpers; + sourceTree = ""; + }; 6A137525263BEAFD003F0E5D /* Content */ = { isa = PBXGroup; children = ( @@ -447,6 +457,7 @@ isa = PBXGroup; children = ( 6A30B9C42639EA1700A65598 /* Info.plist */, + 6A08AA6E26AF29B800BA287C /* Helpers */, 6A30B9CE2639EA4C00A65598 /* Modules */, 6A45D7A7263A62B8003EF1C8 /* Scenes */, 6A30B9DC2639EB4300A65598 /* TestHelpers */, @@ -921,6 +932,7 @@ 6A30B9E42639EB6B00A65598 /* XCTestCase+Error.swift in Sources */, 6A30B9D32639EA6700A65598 /* URLProtocolStub.swift in Sources */, 6A30B9DE2639EB5800A65598 /* XCTestCase+Data.swift in Sources */, + 6A08AA7026AF29CA00BA287C /* StateContainerTests.swift in Sources */, 6A8F260E263C59F8008E86F6 /* ContentView+TestHelpers.swift in Sources */, 6A45D837263AE998003EF1C8 /* ImageResponseMapperTests.swift in Sources */, 6A45D7F0263A85B3003EF1C8 /* FeedSnapshotTests.swift in Sources */, diff --git a/YoloTests/Helpers/StateContainerTests.swift b/YoloTests/Helpers/StateContainerTests.swift new file mode 100644 index 0000000..c54e513 --- /dev/null +++ b/YoloTests/Helpers/StateContainerTests.swift @@ -0,0 +1,30 @@ +// +// StateContainerTests.swift +// YoloTests +// +// Created by Gordon Smith on 26/07/2021. +// + +import XCTest +import Combine + +class StateContainer { + private(set) var state: CurrentValueSubject + + init(state: T) { + self.state = .init(state) + } +} + +class StateContainerTests: XCTestCase { + + func test_on_init_emits_initial_state() { + let state = "initial state" + let sut = StateContainer(state: state) + var output: [String] = [] + _ = sut.state + .sink(receiveValue: { output.append($0) }) + + XCTAssertEqual(output, [state]) + } +} From 415b56b0d7c5343bedce19fe78fc9436640cf7a2 Mon Sep 17 00:00:00 2001 From: Gordon Smith Date: Mon, 26 Jul 2021 19:09:05 +0100 Subject: [PATCH 02/28] test: on init with no initial state delivers reducer default state --- YoloTests/Helpers/StateContainerTests.swift | 22 ++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/YoloTests/Helpers/StateContainerTests.swift b/YoloTests/Helpers/StateContainerTests.swift index c54e513..1b3fefc 100644 --- a/YoloTests/Helpers/StateContainerTests.swift +++ b/YoloTests/Helpers/StateContainerTests.swift @@ -8,11 +8,17 @@ import XCTest import Combine +typealias StateMapper = (_ state: T?) -> T + class StateContainer { private(set) var state: CurrentValueSubject - init(state: T) { - self.state = .init(state) + init(state: T?, mapper: @escaping StateMapper) { + if let state = state { + self.state = .init(state) + } else { + self.state = .init(mapper(nil)) + } } } @@ -20,7 +26,17 @@ class StateContainerTests: XCTestCase { func test_on_init_emits_initial_state() { let state = "initial state" - let sut = StateContainer(state: state) + let sut = StateContainer(state: state, mapper: { _ in state }) + var output: [String] = [] + _ = sut.state + .sink(receiveValue: { output.append($0) }) + + XCTAssertEqual(output, [state]) + } + + func test_on_init_with_no_initial_state_delivers_reducer_default_state() { + let state = "mapper state" + let sut = StateContainer(state: nil, mapper: { _ in state }) var output: [String] = [] _ = sut.state .sink(receiveValue: { output.append($0) }) From ba901acef0a4c9d1870bd82e371f2a994e61877a Mon Sep 17 00:00:00 2001 From: Gordon Smith Date: Mon, 26 Jul 2021 19:10:32 +0100 Subject: [PATCH 03/28] test: fix snapshot(s) --- .../COMMENT_WITH_MULTIPLE_LINES_light.png | Bin 74159 -> 74158 bytes .../UI/snapshots/FEED_WITH_CONTENT_light.png | Bin 162901 -> 162899 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/YoloTests/Modules/Comments/UI/snapshots/COMMENT_WITH_MULTIPLE_LINES_light.png b/YoloTests/Modules/Comments/UI/snapshots/COMMENT_WITH_MULTIPLE_LINES_light.png index 21283e19938f1879c56a378632633f97a970715a..69d772d242cb23fa8530baa9e89b875c50cb41f4 100644 GIT binary patch delta 25922 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 J%Io$~{|D{<_1FLa delta 25913 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 z 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

PzFt_4G9YPm-th<|{ZD~mck>o83Kwg!e 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 diff --git a/YoloTests/Modules/Feed/UI/snapshots/FEED_WITH_CONTENT_light.png b/YoloTests/Modules/Feed/UI/snapshots/FEED_WITH_CONTENT_light.png index ddcd874af6563e678465b4eb4554a22ac4433793..cf4e1dd25cab6ac2e08a8afad0a406034094355d 100644 GIT binary patch delta 17492 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 delta 17511 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 BG7$g( From 6db20e2f4e5830b42b0fe90c3f1e797b00de7288 Mon Sep 17 00:00:00 2001 From: Gordon Smith Date: Mon, 26 Jul 2021 19:27:42 +0100 Subject: [PATCH 04/28] feat: extend `StateMapper` w/ support for new `Event` --- YoloTests/Helpers/StateContainerTests.swift | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/YoloTests/Helpers/StateContainerTests.swift b/YoloTests/Helpers/StateContainerTests.swift index 1b3fefc..382d149 100644 --- a/YoloTests/Helpers/StateContainerTests.swift +++ b/YoloTests/Helpers/StateContainerTests.swift @@ -8,7 +8,9 @@ import XCTest import Combine -typealias StateMapper = (_ state: T?) -> T +protocol Event { } + +typealias StateMapper = (_ state: T?, _ event: Event) -> T class StateContainer { private(set) var state: CurrentValueSubject @@ -17,16 +19,20 @@ class StateContainer { if let state = state { self.state = .init(state) } else { - self.state = .init(mapper(nil)) + self.state = .init(mapper(nil, StateInit())) } } } +private extension StateContainer { + struct StateInit: Event { } +} + class StateContainerTests: XCTestCase { func test_on_init_emits_initial_state() { let state = "initial state" - let sut = StateContainer(state: state, mapper: { _ in state }) + let sut = StateContainer(state: state, mapper: { _, _ in state }) var output: [String] = [] _ = sut.state .sink(receiveValue: { output.append($0) }) @@ -36,7 +42,7 @@ class StateContainerTests: XCTestCase { func test_on_init_with_no_initial_state_delivers_reducer_default_state() { let state = "mapper state" - let sut = StateContainer(state: nil, mapper: { _ in state }) + let sut = StateContainer(state: nil, mapper: { _, _ in state }) var output: [String] = [] _ = sut.state .sink(receiveValue: { output.append($0) }) From ab685b84e55114e0a7e5bffd795c297d547758c7 Mon Sep 17 00:00:00 2001 From: Gordon Smith Date: Mon, 26 Jul 2021 19:43:25 +0100 Subject: [PATCH 05/28] test: on event dispatch notifies mapper of received event --- YoloTests/Helpers/StateContainerTests.swift | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/YoloTests/Helpers/StateContainerTests.swift b/YoloTests/Helpers/StateContainerTests.swift index 382d149..e830d9b 100644 --- a/YoloTests/Helpers/StateContainerTests.swift +++ b/YoloTests/Helpers/StateContainerTests.swift @@ -14,13 +14,20 @@ typealias StateMapper = (_ state: T?, _ event: Event) -> T class StateContainer { private(set) var state: CurrentValueSubject - + private let mapper: StateMapper + init(state: T?, mapper: @escaping StateMapper) { if let state = state { self.state = .init(state) } else { self.state = .init(mapper(nil, StateInit())) } + self.mapper = mapper + } + + func dispatch(_ event: Event) { + let next = mapper(state.value, event) + state.send(next) } } @@ -49,4 +56,16 @@ class StateContainerTests: XCTestCase { XCTAssertEqual(output, [state]) } + + func test_on_event_dispatch_notifies_mapper_of_received_event() { + + struct AnyEvent: Event { } + + var output: [Event] = [] + let sut = StateContainer(state: "any", mapper: { _, event in output.append(event); return "any" }) + + sut.dispatch(AnyEvent()) + XCTAssertEqual(output.count, 1) + XCTAssertNotNil(output.first as? AnyEvent) + } } From bf882c6622138f2842ee08e6e4885d830b7eb3a5 Mon Sep 17 00:00:00 2001 From: Gordon Smith Date: Mon, 26 Jul 2021 19:48:13 +0100 Subject: [PATCH 06/28] test: on event dispatch delivers current state to mapper --- YoloTests/Helpers/StateContainerTests.swift | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/YoloTests/Helpers/StateContainerTests.swift b/YoloTests/Helpers/StateContainerTests.swift index e830d9b..a58472e 100644 --- a/YoloTests/Helpers/StateContainerTests.swift +++ b/YoloTests/Helpers/StateContainerTests.swift @@ -57,6 +57,20 @@ class StateContainerTests: XCTestCase { XCTAssertEqual(output, [state]) } + func test_on_event_dispatch_delivers_current_state_to_mapper() { + + struct AnyEvent: Event { } + + let state = "initial state" + var output: [String?] = [] + let sut = StateContainer(state: state, mapper: { state, _ in output.append(state); return "any" }) + + sut.dispatch(AnyEvent()) + + XCTAssertEqual(output.count, 1) + XCTAssertEqual(output, [state]) + } + func test_on_event_dispatch_notifies_mapper_of_received_event() { struct AnyEvent: Event { } From 78bb20084d2f4f3f635a763ccc12d2c916c0b5ca Mon Sep 17 00:00:00 2001 From: Gordon Smith Date: Mon, 26 Jul 2021 19:52:25 +0100 Subject: [PATCH 07/28] refactor: move `StateContainer` to own file in production target --- Yolo.xcodeproj/project.pbxproj | 4 +++ Yolo/Helpers/StateContainer.swift | 36 +++++++++++++++++++++ YoloTests/Helpers/StateContainerTests.swift | 28 +--------------- 3 files changed, 41 insertions(+), 27 deletions(-) create mode 100644 Yolo/Helpers/StateContainer.swift diff --git a/Yolo.xcodeproj/project.pbxproj b/Yolo.xcodeproj/project.pbxproj index 326ed1b..6d948d5 100644 --- a/Yolo.xcodeproj/project.pbxproj +++ b/Yolo.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 6A08AA7026AF29CA00BA287C /* StateContainerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A08AA6F26AF29CA00BA287C /* StateContainerTests.swift */; }; + 6A08AA7226AF3B9600BA287C /* StateContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A08AA7126AF3B9600BA287C /* StateContainer.swift */; }; 6A137528263BEB4D003F0E5D /* Content.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A137527263BEB4D003F0E5D /* Content.swift */; }; 6A13752F263BEC40003F0E5D /* ContentResponseMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A13752E263BEC40003F0E5D /* ContentResponseMapper.swift */; }; 6A137537263BED60003F0E5D /* ContentResponseMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A137536263BED60003F0E5D /* ContentResponseMapperTests.swift */; }; @@ -103,6 +104,7 @@ /* Begin PBXFileReference section */ 6A08AA6F26AF29CA00BA287C /* StateContainerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StateContainerTests.swift; sourceTree = ""; }; + 6A08AA7126AF3B9600BA287C /* StateContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StateContainer.swift; sourceTree = ""; }; 6A137527263BEB4D003F0E5D /* Content.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Content.swift; sourceTree = ""; }; 6A13752E263BEC40003F0E5D /* ContentResponseMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentResponseMapper.swift; sourceTree = ""; }; 6A137536263BED60003F0E5D /* ContentResponseMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentResponseMapperTests.swift; sourceTree = ""; }; @@ -627,6 +629,7 @@ 6AE78CA7263C42F500E350FB /* Interactions+Toggle.swift */, 6A45D7CE263A78CB003EF1C8 /* Publisher+MainQueue.swift */, 6AE02CAE263B1E310089B39F /* ResourcePresentationAdapter.swift */, + 6A08AA7126AF3B9600BA287C /* StateContainer.swift */, ); path = Helpers; sourceTree = ""; @@ -1007,6 +1010,7 @@ 6A45D7C0263A6E65003EF1C8 /* FeedUIComposer.swift in Sources */, 6A45D7E5263A7C8A003EF1C8 /* FeedCardCellController.swift in Sources */, 6A26215A263BDE7A00956E14 /* CommentCellController.swift in Sources */, + 6A08AA7226AF3B9600BA287C /* StateContainer.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Yolo/Helpers/StateContainer.swift b/Yolo/Helpers/StateContainer.swift new file mode 100644 index 0000000..35982b1 --- /dev/null +++ b/Yolo/Helpers/StateContainer.swift @@ -0,0 +1,36 @@ +// +// StateContainer.swift +// Yolo +// +// Created by Gordon Smith on 26/07/2021. +// + +import Foundation +import Combine + +public protocol Event { } + +public typealias StateMapper = (_ state: T?, _ event: Event) -> T + +public final class StateContainer { + private(set) public var state: CurrentValueSubject + private let mapper: StateMapper + + public init(state: T?, mapper: @escaping StateMapper) { + if let state = state { + self.state = .init(state) + } else { + self.state = .init(mapper(nil, StateInit())) + } + self.mapper = mapper + } + + public func dispatch(_ event: Event) { + let next = mapper(state.value, event) + state.send(next) + } +} + +private extension StateContainer { + struct StateInit: Event { } +} diff --git a/YoloTests/Helpers/StateContainerTests.swift b/YoloTests/Helpers/StateContainerTests.swift index a58472e..9efa44e 100644 --- a/YoloTests/Helpers/StateContainerTests.swift +++ b/YoloTests/Helpers/StateContainerTests.swift @@ -7,33 +7,7 @@ import XCTest import Combine - -protocol Event { } - -typealias StateMapper = (_ state: T?, _ event: Event) -> T - -class StateContainer { - private(set) var state: CurrentValueSubject - private let mapper: StateMapper - - init(state: T?, mapper: @escaping StateMapper) { - if let state = state { - self.state = .init(state) - } else { - self.state = .init(mapper(nil, StateInit())) - } - self.mapper = mapper - } - - func dispatch(_ event: Event) { - let next = mapper(state.value, event) - state.send(next) - } -} - -private extension StateContainer { - struct StateInit: Event { } -} +import Yolo class StateContainerTests: XCTestCase { From d6c01973b7bc88e1515123e9e71e7fc99f8a9e6c Mon Sep 17 00:00:00 2001 From: Gordon Smith Date: Mon, 26 Jul 2021 20:12:55 +0100 Subject: [PATCH 08/28] test: on init with no state delivers default state --- Yolo.xcodeproj/project.pbxproj | 4 ++++ YoloTests/Helpers/RootMapperTests.swift | 23 +++++++++++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 YoloTests/Helpers/RootMapperTests.swift diff --git a/Yolo.xcodeproj/project.pbxproj b/Yolo.xcodeproj/project.pbxproj index 6d948d5..d47b058 100644 --- a/Yolo.xcodeproj/project.pbxproj +++ b/Yolo.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ 6A08AA7026AF29CA00BA287C /* StateContainerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A08AA6F26AF29CA00BA287C /* StateContainerTests.swift */; }; 6A08AA7226AF3B9600BA287C /* StateContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A08AA7126AF3B9600BA287C /* StateContainer.swift */; }; + 6A08AA7426AF3F9700BA287C /* RootMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A08AA7326AF3F9700BA287C /* RootMapperTests.swift */; }; 6A137528263BEB4D003F0E5D /* Content.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A137527263BEB4D003F0E5D /* Content.swift */; }; 6A13752F263BEC40003F0E5D /* ContentResponseMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A13752E263BEC40003F0E5D /* ContentResponseMapper.swift */; }; 6A137537263BED60003F0E5D /* ContentResponseMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A137536263BED60003F0E5D /* ContentResponseMapperTests.swift */; }; @@ -105,6 +106,7 @@ /* Begin PBXFileReference section */ 6A08AA6F26AF29CA00BA287C /* StateContainerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StateContainerTests.swift; sourceTree = ""; }; 6A08AA7126AF3B9600BA287C /* StateContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StateContainer.swift; sourceTree = ""; }; + 6A08AA7326AF3F9700BA287C /* RootMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootMapperTests.swift; sourceTree = ""; }; 6A137527263BEB4D003F0E5D /* Content.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Content.swift; sourceTree = ""; }; 6A13752E263BEC40003F0E5D /* ContentResponseMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentResponseMapper.swift; sourceTree = ""; }; 6A137536263BED60003F0E5D /* ContentResponseMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentResponseMapperTests.swift; sourceTree = ""; }; @@ -214,6 +216,7 @@ isa = PBXGroup; children = ( 6A08AA6F26AF29CA00BA287C /* StateContainerTests.swift */, + 6A08AA7326AF3F9700BA287C /* RootMapperTests.swift */, ); path = Helpers; sourceTree = ""; @@ -937,6 +940,7 @@ 6A30B9DE2639EB5800A65598 /* XCTestCase+Data.swift in Sources */, 6A08AA7026AF29CA00BA287C /* StateContainerTests.swift in Sources */, 6A8F260E263C59F8008E86F6 /* ContentView+TestHelpers.swift in Sources */, + 6A08AA7426AF3F9700BA287C /* RootMapperTests.swift in Sources */, 6A45D837263AE998003EF1C8 /* ImageResponseMapperTests.swift in Sources */, 6A45D7F0263A85B3003EF1C8 /* FeedSnapshotTests.swift in Sources */, 6AC229EC263C2B3D0061718F /* InteractionResposneMapperTests.swift in Sources */, diff --git a/YoloTests/Helpers/RootMapperTests.swift b/YoloTests/Helpers/RootMapperTests.swift new file mode 100644 index 0000000..a522dbd --- /dev/null +++ b/YoloTests/Helpers/RootMapperTests.swift @@ -0,0 +1,23 @@ +// +// RootMapperTests.swift +// YoloTests +// +// Created by Gordon Smith on 26/07/2021. +// + +import XCTest +import Yolo + +let rootMapper: StateMapper = { state, event in + var state = state ?? "any state" + + return state +} + +class RootMapperTests: XCTestCase { + func test_on_init_with_no_state_delivers_default_state() { + struct AnyEvent: Event { } + let output = rootMapper(nil, AnyEvent()) + XCTAssertNotNil(output) + } +} From f0132bfbb61f561e4a829ac32b662670cc7223f7 Mon Sep 17 00:00:00 2001 From: Gordon Smith Date: Mon, 26 Jul 2021 20:14:34 +0100 Subject: [PATCH 09/28] test: on init with state delivers given state --- YoloTests/Helpers/RootMapperTests.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/YoloTests/Helpers/RootMapperTests.swift b/YoloTests/Helpers/RootMapperTests.swift index a522dbd..d765c60 100644 --- a/YoloTests/Helpers/RootMapperTests.swift +++ b/YoloTests/Helpers/RootMapperTests.swift @@ -20,4 +20,11 @@ class RootMapperTests: XCTestCase { let output = rootMapper(nil, AnyEvent()) XCTAssertNotNil(output) } + + func test_on_init_with_state_delivers_given_state() { + struct AnyEvent: Event { } + let state = "initial state" + let output = rootMapper(state, AnyEvent()) + XCTAssertEqual(output, state) + } } From feeea8caa31c872cf17cf99d0e37a241da620e54 Mon Sep 17 00:00:00 2001 From: Gordon Smith Date: Mon, 26 Jul 2021 20:17:49 +0100 Subject: [PATCH 10/28] refactor: move `rootMapper` to own file in production target --- Yolo/Helpers/StateContainer.swift | 10 ++++++++++ YoloTests/Helpers/RootMapperTests.swift | 10 ++-------- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/Yolo/Helpers/StateContainer.swift b/Yolo/Helpers/StateContainer.swift index 35982b1..48977c1 100644 --- a/Yolo/Helpers/StateContainer.swift +++ b/Yolo/Helpers/StateContainer.swift @@ -34,3 +34,13 @@ public final class StateContainer { private extension StateContainer { struct StateInit: Event { } } + +public struct AppState: Equatable { + public init() { } +} + +public let rootMapper: StateMapper = { state, event in + var state = state ?? AppState() + + return state +} diff --git a/YoloTests/Helpers/RootMapperTests.swift b/YoloTests/Helpers/RootMapperTests.swift index d765c60..17e9273 100644 --- a/YoloTests/Helpers/RootMapperTests.swift +++ b/YoloTests/Helpers/RootMapperTests.swift @@ -8,22 +8,16 @@ import XCTest import Yolo -let rootMapper: StateMapper = { state, event in - var state = state ?? "any state" - - return state -} - class RootMapperTests: XCTestCase { func test_on_init_with_no_state_delivers_default_state() { struct AnyEvent: Event { } let output = rootMapper(nil, AnyEvent()) - XCTAssertNotNil(output) + XCTAssertEqual(output, AppState()) } func test_on_init_with_state_delivers_given_state() { struct AnyEvent: Event { } - let state = "initial state" + let state = AppState() let output = rootMapper(state, AnyEvent()) XCTAssertEqual(output, state) } From ac6920a3e84be3a8110d2dd3ea9476ac64be4da7 Mon Sep 17 00:00:00 2001 From: Gordon Smith Date: Mon, 26 Jul 2021 20:26:01 +0100 Subject: [PATCH 11/28] feat: create `StateContainer` instance --- Yolo/Helpers/StateContainer.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Yolo/Helpers/StateContainer.swift b/Yolo/Helpers/StateContainer.swift index 48977c1..2bb0a54 100644 --- a/Yolo/Helpers/StateContainer.swift +++ b/Yolo/Helpers/StateContainer.swift @@ -12,6 +12,8 @@ public protocol Event { } public typealias StateMapper = (_ state: T?, _ event: Event) -> T +typealias Store = StateContainer + public final class StateContainer { private(set) public var state: CurrentValueSubject private let mapper: StateMapper From 69309eff7e552b38798ea1115d35d02b5561e5fa Mon Sep 17 00:00:00 2001 From: Gordon Smith Date: Mon, 26 Jul 2021 20:38:07 +0100 Subject: [PATCH 12/28] feat: on feed load success dispatches event to store --- Yolo/Application/SceneDelegate.swift | 10 +++++++++- Yolo/Helpers/StateContainer.swift | 4 ++++ .../Scenes/Feed/FeedAcceptanceTests.swift | 19 +++++++++++++++++-- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/Yolo/Application/SceneDelegate.swift b/Yolo/Application/SceneDelegate.swift index a7c0b01..613251f 100644 --- a/Yolo/Application/SceneDelegate.swift +++ b/Yolo/Application/SceneDelegate.swift @@ -23,9 +23,14 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { private lazy var baseURL = URL(string: "https://powerful-wave-91495.herokuapp.com/")! - convenience init(httpClient: HTTPClient) { + private lazy var store: Store = { + Store(state: nil, mapper: rootMapper) + }() + + convenience init(httpClient: HTTPClient, store: Store) { self.init() self.httpClient = httpClient + self.store = store } func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { @@ -82,6 +87,9 @@ private extension SceneDelegate { .dispatchPublisher(for: request) .tryMap(FeedResponseMapper.map) .map { $0.items } + .handleEvents(receiveOutput: { [store] _ in + store.dispatch(FeedLoadedEvent()) + }) .eraseToAnyPublisher() } diff --git a/Yolo/Helpers/StateContainer.swift b/Yolo/Helpers/StateContainer.swift index 2bb0a54..69b02a6 100644 --- a/Yolo/Helpers/StateContainer.swift +++ b/Yolo/Helpers/StateContainer.swift @@ -46,3 +46,7 @@ public let rootMapper: StateMapper = { state, event in return state } + +struct FeedLoadedEvent: Event { + +} diff --git a/YoloTests/Scenes/Feed/FeedAcceptanceTests.swift b/YoloTests/Scenes/Feed/FeedAcceptanceTests.swift index 3573474..4503a30 100644 --- a/YoloTests/Scenes/Feed/FeedAcceptanceTests.swift +++ b/YoloTests/Scenes/Feed/FeedAcceptanceTests.swift @@ -42,11 +42,26 @@ class FeedAcceptanceTests: XCTestCase { let view = content.contentView() as? ContentView XCTAssertEqual(view?.renderedImage, makeCardImageData()) } + + func test_on_feed_load_success_dispatches_event_to_store() { + var output: [FeedLoadedEvent] = [] + let sut = launch(httpClient: .online(response), store: Store(state: .init(), mapper: { state, event in + if let event = event as? FeedLoadedEvent { + output.append(event) + } else { + XCTFail("Expected `FeedLoadedEvent` but got \(type(of: event)) instead") + } + return state! + })) + + sut.loadViewIfNeeded() + XCTAssertFalse(output.isEmpty) + } } private extension FeedAcceptanceTests { - func launch(httpClient: HTTPClientStub = .offline) -> ListViewController { - let sut = SceneDelegate(httpClient: httpClient) + func launch(httpClient: HTTPClientStub = .offline, store: Store = Store(state: nil, mapper: { _, _ in AppState() })) -> ListViewController { + let sut = SceneDelegate(httpClient: httpClient, store: store) let window = UIWindow(frame: .zero) sut.configure(window: window) From 42e6abaca2797047cc102dc8ef612fd3f507fa21 Mon Sep 17 00:00:00 2001 From: Gordon Smith Date: Mon, 26 Jul 2021 20:45:43 +0100 Subject: [PATCH 13/28] feat: deliver items using new `FeedLoadedEvent` --- Yolo/Application/SceneDelegate.swift | 4 ++-- Yolo/Helpers/StateContainer.swift | 2 +- YoloTests/Scenes/Feed/FeedAcceptanceTests.swift | 3 ++- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/Yolo/Application/SceneDelegate.swift b/Yolo/Application/SceneDelegate.swift index 613251f..8b5de32 100644 --- a/Yolo/Application/SceneDelegate.swift +++ b/Yolo/Application/SceneDelegate.swift @@ -87,8 +87,8 @@ private extension SceneDelegate { .dispatchPublisher(for: request) .tryMap(FeedResponseMapper.map) .map { $0.items } - .handleEvents(receiveOutput: { [store] _ in - store.dispatch(FeedLoadedEvent()) + .handleEvents(receiveOutput: { [store] items in + store.dispatch(FeedLoadedEvent(payload: items)) }) .eraseToAnyPublisher() } diff --git a/Yolo/Helpers/StateContainer.swift b/Yolo/Helpers/StateContainer.swift index 69b02a6..b72c77c 100644 --- a/Yolo/Helpers/StateContainer.swift +++ b/Yolo/Helpers/StateContainer.swift @@ -48,5 +48,5 @@ public let rootMapper: StateMapper = { state, event in } struct FeedLoadedEvent: Event { - + let payload: [FeedItem] } diff --git a/YoloTests/Scenes/Feed/FeedAcceptanceTests.swift b/YoloTests/Scenes/Feed/FeedAcceptanceTests.swift index 4503a30..8542d8a 100644 --- a/YoloTests/Scenes/Feed/FeedAcceptanceTests.swift +++ b/YoloTests/Scenes/Feed/FeedAcceptanceTests.swift @@ -55,7 +55,8 @@ class FeedAcceptanceTests: XCTestCase { })) sut.loadViewIfNeeded() - XCTAssertFalse(output.isEmpty) + XCTAssertEqual(output.count, 1) + XCTAssertEqual(output.first?.payload.count, sut.numberOfRenderedFeedItems) } } From 90d57a54c6c5052d0337558f5d01c7f2a345e6b1 Mon Sep 17 00:00:00 2001 From: Gordon Smith Date: Mon, 26 Jul 2021 20:48:44 +0100 Subject: [PATCH 14/28] refactor: move payload index look up to helper method --- YoloTests/Scenes/Feed/FeedAcceptanceTests.swift | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/YoloTests/Scenes/Feed/FeedAcceptanceTests.swift b/YoloTests/Scenes/Feed/FeedAcceptanceTests.swift index 8542d8a..8022b09 100644 --- a/YoloTests/Scenes/Feed/FeedAcceptanceTests.swift +++ b/YoloTests/Scenes/Feed/FeedAcceptanceTests.swift @@ -56,7 +56,9 @@ class FeedAcceptanceTests: XCTestCase { sut.loadViewIfNeeded() XCTAssertEqual(output.count, 1) - XCTAssertEqual(output.first?.payload.count, sut.numberOfRenderedFeedItems) + + let payload = output.payload() + XCTAssertEqual(payload.count, sut.numberOfRenderedFeedItems) } } @@ -194,3 +196,9 @@ private extension FeedAcceptanceTests { ] as [String : Any] } } + +extension Array where Element == FeedLoadedEvent { + func payload(at index: Int = 0) -> [FeedItem] { + return self[index].payload + } +} From caa7c2fb96a7fb168e85863c8a611adc206d987a Mon Sep 17 00:00:00 2001 From: Gordon Smith Date: Tue, 27 Jul 2021 06:27:01 +0100 Subject: [PATCH 15/28] feat: include `OrderedCollections` framework --- Yolo.xcodeproj/project.pbxproj | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/Yolo.xcodeproj/project.pbxproj b/Yolo.xcodeproj/project.pbxproj index d47b058..5468435 100644 --- a/Yolo.xcodeproj/project.pbxproj +++ b/Yolo.xcodeproj/project.pbxproj @@ -3,13 +3,14 @@ archiveVersion = 1; classes = { }; - objectVersion = 50; + objectVersion = 52; objects = { /* Begin PBXBuildFile section */ 6A08AA7026AF29CA00BA287C /* StateContainerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A08AA6F26AF29CA00BA287C /* StateContainerTests.swift */; }; 6A08AA7226AF3B9600BA287C /* StateContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A08AA7126AF3B9600BA287C /* StateContainer.swift */; }; 6A08AA7426AF3F9700BA287C /* RootMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A08AA7326AF3F9700BA287C /* RootMapperTests.swift */; }; + 6A08AA7726AFD04100BA287C /* OrderedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = 6A08AA7626AFD04100BA287C /* OrderedCollections */; }; 6A137528263BEB4D003F0E5D /* Content.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A137527263BEB4D003F0E5D /* Content.swift */; }; 6A13752F263BEC40003F0E5D /* ContentResponseMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A13752E263BEC40003F0E5D /* ContentResponseMapper.swift */; }; 6A137537263BED60003F0E5D /* ContentResponseMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A137536263BED60003F0E5D /* ContentResponseMapperTests.swift */; }; @@ -206,6 +207,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 6A08AA7726AFD04100BA287C /* OrderedCollections in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -860,6 +862,9 @@ dependencies = ( ); name = Yolo; + packageProductDependencies = ( + 6A08AA7626AFD04100BA287C /* OrderedCollections */, + ); productName = Yolo; productReference = 6A79BE792639D918000062A7 /* Yolo.app */; productType = "com.apple.product-type.application"; @@ -892,6 +897,9 @@ Base, ); mainGroup = 6A79BE702639D918000062A7; + packageReferences = ( + 6A08AA7526AFD04100BA287C /* XCRemoteSwiftPackageReference "swift-collections" */, + ); productRefGroup = 6A79BE7A2639D918000062A7 /* Products */; projectDirPath = ""; projectRoot = ""; @@ -1272,6 +1280,25 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 6A08AA7526AFD04100BA287C /* XCRemoteSwiftPackageReference "swift-collections" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/apple/swift-collections"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.0.4; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 6A08AA7626AFD04100BA287C /* OrderedCollections */ = { + isa = XCSwiftPackageProductDependency; + package = 6A08AA7526AFD04100BA287C /* XCRemoteSwiftPackageReference "swift-collections" */; + productName = OrderedCollections; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 6A79BE712639D918000062A7 /* Project object */; } From 3a35b24ca39adb1cb5f6028268a0b2a42e929915 Mon Sep 17 00:00:00 2001 From: Gordon Smith Date: Tue, 27 Jul 2021 06:32:27 +0100 Subject: [PATCH 16/28] feat: populate home feed via our new state container --- Yolo/Application/SceneDelegate.swift | 12 ++++++++++++ Yolo/Helpers/StateContainer.swift | 15 ++++++++++++++- YoloTests/Scenes/Feed/FeedAcceptanceTests.swift | 5 +---- 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/Yolo/Application/SceneDelegate.swift b/Yolo/Application/SceneDelegate.swift index 8b5de32..29c247b 100644 --- a/Yolo/Application/SceneDelegate.swift +++ b/Yolo/Application/SceneDelegate.swift @@ -90,6 +90,18 @@ private extension SceneDelegate { .handleEvents(receiveOutput: { [store] items in store.dispatch(FeedLoadedEvent(payload: items)) }) + .flatMap { [store] _ in + store.state + .map { state in + state.feed.reduce([FeedItem](), { acc, e in + var acc = acc + acc.append(e.value) + return acc + }) + } + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } .eraseToAnyPublisher() } diff --git a/Yolo/Helpers/StateContainer.swift b/Yolo/Helpers/StateContainer.swift index b72c77c..5c71a1e 100644 --- a/Yolo/Helpers/StateContainer.swift +++ b/Yolo/Helpers/StateContainer.swift @@ -7,6 +7,7 @@ import Foundation import Combine +import OrderedCollections public protocol Event { } @@ -38,12 +39,24 @@ private extension StateContainer { } public struct AppState: Equatable { - public init() { } + public var feed: OrderedDictionary + public init(feed: OrderedDictionary = [:]) { + self.feed = feed + } } public let rootMapper: StateMapper = { state, event in var state = state ?? AppState() + if let event = event as? FeedLoadedEvent { + + event.payload.forEach { item in + state.feed[item.id] = item + } + + return state + } + return state } diff --git a/YoloTests/Scenes/Feed/FeedAcceptanceTests.swift b/YoloTests/Scenes/Feed/FeedAcceptanceTests.swift index 8022b09..d85aff9 100644 --- a/YoloTests/Scenes/Feed/FeedAcceptanceTests.swift +++ b/YoloTests/Scenes/Feed/FeedAcceptanceTests.swift @@ -56,14 +56,11 @@ class FeedAcceptanceTests: XCTestCase { sut.loadViewIfNeeded() XCTAssertEqual(output.count, 1) - - let payload = output.payload() - XCTAssertEqual(payload.count, sut.numberOfRenderedFeedItems) } } private extension FeedAcceptanceTests { - func launch(httpClient: HTTPClientStub = .offline, store: Store = Store(state: nil, mapper: { _, _ in AppState() })) -> ListViewController { + func launch(httpClient: HTTPClientStub = .offline, store: Store = Store(state: nil, mapper: rootMapper)) -> ListViewController { let sut = SceneDelegate(httpClient: httpClient, store: store) let window = UIWindow(frame: .zero) sut.configure(window: window) From 9e659ab4b692b91252860d31b06e5ecea8a6a804 Mon Sep 17 00:00:00 2001 From: Gordon Smith Date: Tue, 27 Jul 2021 06:37:28 +0100 Subject: [PATCH 17/28] test: on init with no state delivers default state --- Yolo.xcodeproj/project.pbxproj | 4 ++++ YoloTests/Helpers/FeedMapperTests.swift | 25 +++++++++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 YoloTests/Helpers/FeedMapperTests.swift diff --git a/Yolo.xcodeproj/project.pbxproj b/Yolo.xcodeproj/project.pbxproj index 5468435..6997269 100644 --- a/Yolo.xcodeproj/project.pbxproj +++ b/Yolo.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ 6A08AA7226AF3B9600BA287C /* StateContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A08AA7126AF3B9600BA287C /* StateContainer.swift */; }; 6A08AA7426AF3F9700BA287C /* RootMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A08AA7326AF3F9700BA287C /* RootMapperTests.swift */; }; 6A08AA7726AFD04100BA287C /* OrderedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = 6A08AA7626AFD04100BA287C /* OrderedCollections */; }; + 6A08AA7926AFD28D00BA287C /* FeedMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A08AA7826AFD28D00BA287C /* FeedMapperTests.swift */; }; 6A137528263BEB4D003F0E5D /* Content.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A137527263BEB4D003F0E5D /* Content.swift */; }; 6A13752F263BEC40003F0E5D /* ContentResponseMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A13752E263BEC40003F0E5D /* ContentResponseMapper.swift */; }; 6A137537263BED60003F0E5D /* ContentResponseMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A137536263BED60003F0E5D /* ContentResponseMapperTests.swift */; }; @@ -108,6 +109,7 @@ 6A08AA6F26AF29CA00BA287C /* StateContainerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StateContainerTests.swift; sourceTree = ""; }; 6A08AA7126AF3B9600BA287C /* StateContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StateContainer.swift; sourceTree = ""; }; 6A08AA7326AF3F9700BA287C /* RootMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootMapperTests.swift; sourceTree = ""; }; + 6A08AA7826AFD28D00BA287C /* FeedMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedMapperTests.swift; sourceTree = ""; }; 6A137527263BEB4D003F0E5D /* Content.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Content.swift; sourceTree = ""; }; 6A13752E263BEC40003F0E5D /* ContentResponseMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentResponseMapper.swift; sourceTree = ""; }; 6A137536263BED60003F0E5D /* ContentResponseMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentResponseMapperTests.swift; sourceTree = ""; }; @@ -219,6 +221,7 @@ children = ( 6A08AA6F26AF29CA00BA287C /* StateContainerTests.swift */, 6A08AA7326AF3F9700BA287C /* RootMapperTests.swift */, + 6A08AA7826AFD28D00BA287C /* FeedMapperTests.swift */, ); path = Helpers; sourceTree = ""; @@ -943,6 +946,7 @@ 6A45D827263AE77A003EF1C8 /* FeedCardView+TestHelpers.swift in Sources */, 6A8F2606263C5999008E86F6 /* ContentUIIntergrationTests+Assertions.swift in Sources */, 6A8F2623263C5B5B008E86F6 /* UIView+LayoutCycle.swift in Sources */, + 6A08AA7926AFD28D00BA287C /* FeedMapperTests.swift in Sources */, 6A30B9E42639EB6B00A65598 /* XCTestCase+Error.swift in Sources */, 6A30B9D32639EA6700A65598 /* URLProtocolStub.swift in Sources */, 6A30B9DE2639EB5800A65598 /* XCTestCase+Data.swift in Sources */, diff --git a/YoloTests/Helpers/FeedMapperTests.swift b/YoloTests/Helpers/FeedMapperTests.swift new file mode 100644 index 0000000..2d79303 --- /dev/null +++ b/YoloTests/Helpers/FeedMapperTests.swift @@ -0,0 +1,25 @@ +// +// FeedMapperTests.swift +// YoloTests +// +// Created by Gordon Smith on 27/07/2021. +// + +import XCTest +import Yolo + +struct FeedState: Equatable { } + +let feedMapper: StateMapper = { state, event in + FeedState() +} + +class FeedMapperTests: XCTestCase { + + func test_on_init_with_no_state_delivers_default_state() { + struct AnyEvent: Event { } + let output = feedMapper(nil, AnyEvent()) + XCTAssertEqual(output, FeedState()) + } + +} From 6d4079fa9d80151e305f7c4591f037bb66f9c932 Mon Sep 17 00:00:00 2001 From: Gordon Smith Date: Tue, 27 Jul 2021 06:41:44 +0100 Subject: [PATCH 18/28] test: on init with state delivers given state --- YoloTests/Helpers/FeedMapperTests.swift | 32 +++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/YoloTests/Helpers/FeedMapperTests.swift b/YoloTests/Helpers/FeedMapperTests.swift index 2d79303..09eec4f 100644 --- a/YoloTests/Helpers/FeedMapperTests.swift +++ b/YoloTests/Helpers/FeedMapperTests.swift @@ -7,11 +7,15 @@ import XCTest import Yolo +import OrderedCollections -struct FeedState: Equatable { } +struct FeedState: Equatable { + var items: OrderedDictionary = [:] +} let feedMapper: StateMapper = { state, event in - FeedState() + var state = state ?? FeedState() + return state } class FeedMapperTests: XCTestCase { @@ -21,5 +25,29 @@ class FeedMapperTests: XCTestCase { let output = feedMapper(nil, AnyEvent()) XCTAssertEqual(output, FeedState()) } + + func test_on_init_with_state_delivers_given_state() { + struct AnyEvent: Event { } + let item = makeItem() + let state = FeedState(items: [item.id: item]) + let output = feedMapper(state, AnyEvent()) + XCTAssertEqual(output, state) + } } + +private extension FeedMapperTests { + func makeItem() -> FeedItem { + FeedItem( + id: "any", + imageURL: makeURL(), + user: .init(id: "any", name: "any", about: "any", imageURL: makeURL()), + interactions: .init( + isLiked: false, + likes: 0, + comments: 0, + shares: 0 + ) + ) + } +} From 39ed7b53fa931c07d1eacb83d227d33088d33281 Mon Sep 17 00:00:00 2001 From: Gordon Smith Date: Tue, 27 Jul 2021 07:02:48 +0100 Subject: [PATCH 19/28] test: on feed loaded event maps payload to state --- YoloTests/Helpers/FeedMapperTests.swift | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/YoloTests/Helpers/FeedMapperTests.swift b/YoloTests/Helpers/FeedMapperTests.swift index 09eec4f..87e1564 100644 --- a/YoloTests/Helpers/FeedMapperTests.swift +++ b/YoloTests/Helpers/FeedMapperTests.swift @@ -15,6 +15,14 @@ struct FeedState: Equatable { let feedMapper: StateMapper = { state, event in var state = state ?? FeedState() + + if let event = event as? FeedLoadedEvent { + event.payload.forEach { item in + state.items[item.id] = item + } + return state + } + return state } @@ -33,6 +41,14 @@ class FeedMapperTests: XCTestCase { let output = feedMapper(state, AnyEvent()) XCTAssertEqual(output, state) } + + func test_on_feed_loaded_event_maps_payload_to_state() { + let item = makeItem() + let event = FeedLoadedEvent(payload: [item]) + let output = feedMapper(nil, event) + + XCTAssertEqual(output.items, [item.id: item]) + } } From 97af859da010aa8a43d9955dd844ec48f1ea21e4 Mon Sep 17 00:00:00 2001 From: Gordon Smith Date: Tue, 27 Jul 2021 07:13:52 +0100 Subject: [PATCH 20/28] test: does not update state on unhandled event --- YoloTests/Helpers/FeedMapperTests.swift | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/YoloTests/Helpers/FeedMapperTests.swift b/YoloTests/Helpers/FeedMapperTests.swift index 87e1564..a15f152 100644 --- a/YoloTests/Helpers/FeedMapperTests.swift +++ b/YoloTests/Helpers/FeedMapperTests.swift @@ -49,6 +49,19 @@ class FeedMapperTests: XCTestCase { XCTAssertEqual(output.items, [item.id: item]) } + + func test_does_not_update_state_on_unhandled_event() { + struct IgnoredEvent: Event { } + + let item = makeItem() + let event = FeedLoadedEvent(payload: [item]) + + let output1 = feedMapper(nil, event) + XCTAssertEqual(output1.items, [item.id: item]) + + let output2 = feedMapper(output1, IgnoredEvent()) + XCTAssertEqual(output2.items, [item.id: item]) + } } From 62aea17a6036aae6227cb51dd428d33595391f05 Mon Sep 17 00:00:00 2001 From: Gordon Smith Date: Tue, 27 Jul 2021 07:16:04 +0100 Subject: [PATCH 21/28] refactor: move `FeedMapper` to production target --- Yolo/Helpers/StateContainer.swift | 28 ++++++++++++++++++++++--- YoloTests/Helpers/FeedMapperTests.swift | 18 ---------------- 2 files changed, 25 insertions(+), 21 deletions(-) diff --git a/Yolo/Helpers/StateContainer.swift b/Yolo/Helpers/StateContainer.swift index 5c71a1e..7852a49 100644 --- a/Yolo/Helpers/StateContainer.swift +++ b/Yolo/Helpers/StateContainer.swift @@ -53,13 +53,35 @@ public let rootMapper: StateMapper = { state, event in event.payload.forEach { item in state.feed[item.id] = item } - return state } return state } -struct FeedLoadedEvent: Event { - let payload: [FeedItem] +public struct FeedLoadedEvent: Event { + public let payload: [FeedItem] + public init(payload: [FeedItem]) { + self.payload = payload + } +} + +public struct FeedState: Equatable { + public var items: OrderedDictionary + public init(items: OrderedDictionary = [:]) { + self.items = items + } +} + +public let feedMapper: StateMapper = { state, event in + var state = state ?? FeedState() + + if let event = event as? FeedLoadedEvent { + event.payload.forEach { item in + state.items[item.id] = item + } + return state + } + + return state } diff --git a/YoloTests/Helpers/FeedMapperTests.swift b/YoloTests/Helpers/FeedMapperTests.swift index a15f152..d52b2d8 100644 --- a/YoloTests/Helpers/FeedMapperTests.swift +++ b/YoloTests/Helpers/FeedMapperTests.swift @@ -7,24 +7,6 @@ import XCTest import Yolo -import OrderedCollections - -struct FeedState: Equatable { - var items: OrderedDictionary = [:] -} - -let feedMapper: StateMapper = { state, event in - var state = state ?? FeedState() - - if let event = event as? FeedLoadedEvent { - event.payload.forEach { item in - state.items[item.id] = item - } - return state - } - - return state -} class FeedMapperTests: XCTestCase { From 7271862c57bb759fc0674aeed4d70bd5dc190161 Mon Sep 17 00:00:00 2001 From: Gordon Smith Date: Tue, 27 Jul 2021 07:18:29 +0100 Subject: [PATCH 22/28] refactor: move feed state to own sub state type --- Yolo/Application/SceneDelegate.swift | 2 +- Yolo/Helpers/StateContainer.swift | 18 +++++------------- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/Yolo/Application/SceneDelegate.swift b/Yolo/Application/SceneDelegate.swift index 29c247b..fa9dd39 100644 --- a/Yolo/Application/SceneDelegate.swift +++ b/Yolo/Application/SceneDelegate.swift @@ -93,7 +93,7 @@ private extension SceneDelegate { .flatMap { [store] _ in store.state .map { state in - state.feed.reduce([FeedItem](), { acc, e in + state.feed.items.reduce([FeedItem](), { acc, e -> [FeedItem] in var acc = acc acc.append(e.value) return acc diff --git a/Yolo/Helpers/StateContainer.swift b/Yolo/Helpers/StateContainer.swift index 7852a49..e7f7584 100644 --- a/Yolo/Helpers/StateContainer.swift +++ b/Yolo/Helpers/StateContainer.swift @@ -39,24 +39,16 @@ private extension StateContainer { } public struct AppState: Equatable { - public var feed: OrderedDictionary - public init(feed: OrderedDictionary = [:]) { + let feed: FeedState + public init(feed: FeedState = .init()) { self.feed = feed } } public let rootMapper: StateMapper = { state, event in - var state = state ?? AppState() - - if let event = event as? FeedLoadedEvent { - - event.payload.forEach { item in - state.feed[item.id] = item - } - return state - } - - return state + AppState( + feed: feedMapper(state?.feed, event) + ) } public struct FeedLoadedEvent: Event { From a9a15f30bc81adad23dee9edf164909a974926ac Mon Sep 17 00:00:00 2001 From: Gordon Smith Date: Tue, 27 Jul 2021 07:41:54 +0100 Subject: [PATCH 23/28] feat: memoize feed state mapping with new `createSelector` helper methods --- Yolo.xcodeproj/project.pbxproj | 12 ++++++++ Yolo/Application/SceneDelegate.swift | 8 +---- Yolo/Helpers/Memoize.swift | 21 +++++++++++++ Yolo/Helpers/Selector.swift | 26 ++++++++++++++++ Yolo/Helpers/StateContainer.swift | 16 ++++++++-- YoloTests/Helpers/FeedSelectorTests.swift | 36 +++++++++++++++++++++++ 6 files changed, 109 insertions(+), 10 deletions(-) create mode 100644 Yolo/Helpers/Memoize.swift create mode 100644 Yolo/Helpers/Selector.swift create mode 100644 YoloTests/Helpers/FeedSelectorTests.swift diff --git a/Yolo.xcodeproj/project.pbxproj b/Yolo.xcodeproj/project.pbxproj index 6997269..9e60724 100644 --- a/Yolo.xcodeproj/project.pbxproj +++ b/Yolo.xcodeproj/project.pbxproj @@ -12,6 +12,9 @@ 6A08AA7426AF3F9700BA287C /* RootMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A08AA7326AF3F9700BA287C /* RootMapperTests.swift */; }; 6A08AA7726AFD04100BA287C /* OrderedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = 6A08AA7626AFD04100BA287C /* OrderedCollections */; }; 6A08AA7926AFD28D00BA287C /* FeedMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A08AA7826AFD28D00BA287C /* FeedMapperTests.swift */; }; + 6A08AA7B26AFDD4B00BA287C /* Memoize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A08AA7A26AFDD4B00BA287C /* Memoize.swift */; }; + 6A08AA7D26AFDE7800BA287C /* Selector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A08AA7C26AFDE7800BA287C /* Selector.swift */; }; + 6A08AA7F26AFE0D400BA287C /* FeedSelectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A08AA7E26AFE0D400BA287C /* FeedSelectorTests.swift */; }; 6A137528263BEB4D003F0E5D /* Content.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A137527263BEB4D003F0E5D /* Content.swift */; }; 6A13752F263BEC40003F0E5D /* ContentResponseMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A13752E263BEC40003F0E5D /* ContentResponseMapper.swift */; }; 6A137537263BED60003F0E5D /* ContentResponseMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A137536263BED60003F0E5D /* ContentResponseMapperTests.swift */; }; @@ -110,6 +113,9 @@ 6A08AA7126AF3B9600BA287C /* StateContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StateContainer.swift; sourceTree = ""; }; 6A08AA7326AF3F9700BA287C /* RootMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootMapperTests.swift; sourceTree = ""; }; 6A08AA7826AFD28D00BA287C /* FeedMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedMapperTests.swift; sourceTree = ""; }; + 6A08AA7A26AFDD4B00BA287C /* Memoize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Memoize.swift; sourceTree = ""; }; + 6A08AA7C26AFDE7800BA287C /* Selector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Selector.swift; sourceTree = ""; }; + 6A08AA7E26AFE0D400BA287C /* FeedSelectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedSelectorTests.swift; sourceTree = ""; }; 6A137527263BEB4D003F0E5D /* Content.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Content.swift; sourceTree = ""; }; 6A13752E263BEC40003F0E5D /* ContentResponseMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentResponseMapper.swift; sourceTree = ""; }; 6A137536263BED60003F0E5D /* ContentResponseMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentResponseMapperTests.swift; sourceTree = ""; }; @@ -222,6 +228,7 @@ 6A08AA6F26AF29CA00BA287C /* StateContainerTests.swift */, 6A08AA7326AF3F9700BA287C /* RootMapperTests.swift */, 6A08AA7826AFD28D00BA287C /* FeedMapperTests.swift */, + 6A08AA7E26AFE0D400BA287C /* FeedSelectorTests.swift */, ); path = Helpers; sourceTree = ""; @@ -638,6 +645,8 @@ 6A45D7CE263A78CB003EF1C8 /* Publisher+MainQueue.swift */, 6AE02CAE263B1E310089B39F /* ResourcePresentationAdapter.swift */, 6A08AA7126AF3B9600BA287C /* StateContainer.swift */, + 6A08AA7A26AFDD4B00BA287C /* Memoize.swift */, + 6A08AA7C26AFDE7800BA287C /* Selector.swift */, ); path = Helpers; sourceTree = ""; @@ -943,6 +952,7 @@ files = ( 6A8F261F263C5B07008E86F6 /* FeedUIIntegrationTests+LoaderSpy.swift in Sources */, 6A8F2619263C5AD3008E86F6 /* FeedUIIntegrationTests+Assertions.swift in Sources */, + 6A08AA7F26AFE0D400BA287C /* FeedSelectorTests.swift in Sources */, 6A45D827263AE77A003EF1C8 /* FeedCardView+TestHelpers.swift in Sources */, 6A8F2606263C5999008E86F6 /* ContentUIIntergrationTests+Assertions.swift in Sources */, 6A8F2623263C5B5B008E86F6 /* UIView+LayoutCycle.swift in Sources */, @@ -990,6 +1000,7 @@ 6A13752F263BEC40003F0E5D /* ContentResponseMapper.swift in Sources */, 6A137556263BFEF7003F0E5D /* ContentViewController.swift in Sources */, 6AE02CAB263B19DC0089B39F /* ResourcePresenter.swift in Sources */, + 6A08AA7B26AFDD4B00BA287C /* Memoize.swift in Sources */, 6A137528263BEB4D003F0E5D /* Content.swift in Sources */, 6A45D814263AE262003EF1C8 /* Combine+Helpers.swift in Sources */, 6AE02CCB263B326E0089B39F /* UITableView+Dequeueing.swift in Sources */, @@ -1014,6 +1025,7 @@ 6AC229E5263C2AE50061718F /* Interactions.swift in Sources */, 6A26214D263BDC7C00956E14 /* CommentPresenter.swift in Sources */, 6AE02CD9263B41810089B39F /* ResourceViewAdapter.swift in Sources */, + 6A08AA7D26AFDE7800BA287C /* Selector.swift in Sources */, 6AE78CA8263C42F500E350FB /* Interactions+Toggle.swift in Sources */, 6AC229DE263C2A4E0061718F /* InteractionResposneMapper.swift in Sources */, 6A45D82F263AE911003EF1C8 /* ImageResponseMapper.swift in Sources */, diff --git a/Yolo/Application/SceneDelegate.swift b/Yolo/Application/SceneDelegate.swift index fa9dd39..c4fea57 100644 --- a/Yolo/Application/SceneDelegate.swift +++ b/Yolo/Application/SceneDelegate.swift @@ -92,13 +92,7 @@ private extension SceneDelegate { }) .flatMap { [store] _ in store.state - .map { state in - state.feed.items.reduce([FeedItem](), { acc, e -> [FeedItem] in - var acc = acc - acc.append(e.value) - return acc - }) - } + .map(feedSelector) .setFailureType(to: Error.self) .eraseToAnyPublisher() } diff --git a/Yolo/Helpers/Memoize.swift b/Yolo/Helpers/Memoize.swift new file mode 100644 index 0000000..8a1a722 --- /dev/null +++ b/Yolo/Helpers/Memoize.swift @@ -0,0 +1,21 @@ +// +// Memoize.swift +// Yolo +// +// Created by Gordon Smith on 27/07/2021. +// + +import Foundation + +func memoize(_ fn: @escaping (T) -> U) -> (T) -> U { + var memo = Dictionary() + + func result(selector: T) -> U { + if let q = memo[selector] { return q } + let r = fn(selector) + memo[selector] = r + return r + } + + return result +} diff --git a/Yolo/Helpers/Selector.swift b/Yolo/Helpers/Selector.swift new file mode 100644 index 0000000..72a349d --- /dev/null +++ b/Yolo/Helpers/Selector.swift @@ -0,0 +1,26 @@ +// +// Selector.swift +// Yolo +// +// Created by Gordon Smith on 27/07/2021. +// + +import Foundation + +func createSelector(selector1: @escaping (TInput) -> T1, _ combine: @escaping (T1) -> TOutput) -> (TInput) -> TOutput { + memoize { value in + combine(selector1(value)) + } +} + +func createSelector(selector1: @escaping (TInput) -> T1, _ selector2: @escaping (TInput) -> T2, _ combine: @escaping (T1, T2) -> TOutput) -> (TInput) -> TOutput { + memoize { state in + combine(selector1(state), selector2(state)) + } +} + +func createSelector(selector1: @escaping (TInput) -> T1, _ selector2: @escaping (TInput) -> T2, selector3: @escaping (TInput) -> T3, _ combine: @escaping (T1, T2, T3) -> TOutput) -> (TInput) -> TOutput { + memoize { state in + combine(selector1(state), selector2(state), selector3(state)) + } +} diff --git a/Yolo/Helpers/StateContainer.swift b/Yolo/Helpers/StateContainer.swift index e7f7584..1d59273 100644 --- a/Yolo/Helpers/StateContainer.swift +++ b/Yolo/Helpers/StateContainer.swift @@ -38,7 +38,7 @@ private extension StateContainer { struct StateInit: Event { } } -public struct AppState: Equatable { +public struct AppState: Hashable { let feed: FeedState public init(feed: FeedState = .init()) { self.feed = feed @@ -58,7 +58,7 @@ public struct FeedLoadedEvent: Event { } } -public struct FeedState: Equatable { +public struct FeedState: Hashable { public var items: OrderedDictionary public init(items: OrderedDictionary = [:]) { self.items = items @@ -74,6 +74,16 @@ public let feedMapper: StateMapper = { state, event in } return state } - + return state } + +public let stateSelector = { (state: AppState) in state } +public let feedSelector = createSelector(selector1: stateSelector, { state -> [FeedItem] in + let feed = state.feed + return feed.items.reduce([FeedItem]()) { acc, e in + var acc = acc + acc.append(e.value) + return acc + } +}) diff --git a/YoloTests/Helpers/FeedSelectorTests.swift b/YoloTests/Helpers/FeedSelectorTests.swift new file mode 100644 index 0000000..d28c88d --- /dev/null +++ b/YoloTests/Helpers/FeedSelectorTests.swift @@ -0,0 +1,36 @@ +// +// FeedSelectorTests.swift +// YoloTests +// +// Created by Gordon Smith on 27/07/2021. +// + +import XCTest +import Yolo + +class FeedSelectorTests: XCTestCase { + + func test_selector_maps_current_state() { + let item = makeItem() + let state = FeedState(items: [item.id: item]) + let output = feedSelector(.init(feed: state)) + XCTAssertEqual(output, [item]) + } + +} + +private extension FeedSelectorTests { + func makeItem() -> FeedItem { + FeedItem( + id: "any", + imageURL: makeURL(), + user: .init(id: "any", name: "any", about: "any", imageURL: makeURL()), + interactions: .init( + isLiked: false, + likes: 0, + comments: 0, + shares: 0 + ) + ) + } +} From 0b2b7a5c6c80a293b7f2237c75b0a46177e56b51 Mon Sep 17 00:00:00 2001 From: Gordon Smith Date: Tue, 27 Jul 2021 07:52:50 +0100 Subject: [PATCH 24/28] refactor: move store selection behaviour to `Publisher` extension --- Yolo/Application/SceneDelegate.swift | 10 ++-------- Yolo/Helpers/Combine+Helpers.swift | 6 ++++++ Yolo/Helpers/Selector.swift | 12 ------------ 3 files changed, 8 insertions(+), 20 deletions(-) diff --git a/Yolo/Application/SceneDelegate.swift b/Yolo/Application/SceneDelegate.swift index c4fea57..7dce724 100644 --- a/Yolo/Application/SceneDelegate.swift +++ b/Yolo/Application/SceneDelegate.swift @@ -86,17 +86,11 @@ private extension SceneDelegate { return httpClient .dispatchPublisher(for: request) .tryMap(FeedResponseMapper.map) - .map { $0.items } + .map(\.items) .handleEvents(receiveOutput: { [store] items in store.dispatch(FeedLoadedEvent(payload: items)) }) - .flatMap { [store] _ in - store.state - .map(feedSelector) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } - .eraseToAnyPublisher() + .select(from: store, using: feedSelector) } func makeRemoteImageLoader(_ imageURL: URL) -> AnyPublisher { diff --git a/Yolo/Helpers/Combine+Helpers.swift b/Yolo/Helpers/Combine+Helpers.swift index 6f0f633..67e191a 100644 --- a/Yolo/Helpers/Combine+Helpers.swift +++ b/Yolo/Helpers/Combine+Helpers.swift @@ -23,3 +23,9 @@ extension HTTPClient { .eraseToAnyPublisher() } } + +extension Publisher { + func select(from store: Store, using selector: @escaping (AppState) -> Output) -> AnyPublisher { + flatMap { _ in store.state.map(selector).setFailureType(to: Failure.self) }.eraseToAnyPublisher() + } +} diff --git a/Yolo/Helpers/Selector.swift b/Yolo/Helpers/Selector.swift index 72a349d..845c841 100644 --- a/Yolo/Helpers/Selector.swift +++ b/Yolo/Helpers/Selector.swift @@ -12,15 +12,3 @@ func createSelector(selector1: @escaping (TInput) combine(selector1(value)) } } - -func createSelector(selector1: @escaping (TInput) -> T1, _ selector2: @escaping (TInput) -> T2, _ combine: @escaping (T1, T2) -> TOutput) -> (TInput) -> TOutput { - memoize { state in - combine(selector1(state), selector2(state)) - } -} - -func createSelector(selector1: @escaping (TInput) -> T1, _ selector2: @escaping (TInput) -> T2, selector3: @escaping (TInput) -> T3, _ combine: @escaping (T1, T2, T3) -> TOutput) -> (TInput) -> TOutput { - memoize { state in - combine(selector1(state), selector2(state), selector3(state)) - } -} From e6f9f8e73bb59925b672ec9a5e7cd193ac85ab9a Mon Sep 17 00:00:00 2001 From: Gordon Smith Date: Tue, 27 Jul 2021 18:24:31 +0100 Subject: [PATCH 25/28] feat: on toggle like dispatches event to store --- Yolo/Application/SceneDelegate.swift | 1 + Yolo/Helpers/StateContainer.swift | 7 ++++ .../Scenes/Feed/FeedAcceptanceTests.swift | 35 +++++++++++++++++++ 3 files changed, 43 insertions(+) diff --git a/Yolo/Application/SceneDelegate.swift b/Yolo/Application/SceneDelegate.swift index 7dce724..01afbc8 100644 --- a/Yolo/Application/SceneDelegate.swift +++ b/Yolo/Application/SceneDelegate.swift @@ -136,6 +136,7 @@ private extension SceneDelegate { } func makeRemoteInteractionService(id: String, interaction: Interaction) -> AnyPublisher { + store.dispatch(LikeInteractionEvent(payload: (id, interaction == .like))) var request = URLRequest( url: baseURL .appendingPathComponent("interactions") diff --git a/Yolo/Helpers/StateContainer.swift b/Yolo/Helpers/StateContainer.swift index 1d59273..e559bef 100644 --- a/Yolo/Helpers/StateContainer.swift +++ b/Yolo/Helpers/StateContainer.swift @@ -87,3 +87,10 @@ public let feedSelector = createSelector(selector1: stateSelector, { state -> [F return acc } }) + +public struct LikeInteractionEvent: Event { + public let payload: (id: String, isLiked: Bool) + public init(payload: (id: String, isLiked: Bool)) { + self.payload = payload + } +} diff --git a/YoloTests/Scenes/Feed/FeedAcceptanceTests.swift b/YoloTests/Scenes/Feed/FeedAcceptanceTests.swift index d85aff9..9e1edc0 100644 --- a/YoloTests/Scenes/Feed/FeedAcceptanceTests.swift +++ b/YoloTests/Scenes/Feed/FeedAcceptanceTests.swift @@ -57,6 +57,35 @@ class FeedAcceptanceTests: XCTestCase { sut.loadViewIfNeeded() XCTAssertEqual(output.count, 1) } + + func test_on_toggle_like_dispatches_event_to_store() { + var output: [LikeInteractionEvent] = [] + let item = FeedItem( + id: "any", + imageURL: makeURL(), + user: .init(id: "any", name: "any name", about: "any", imageURL: makeURL()), + interactions: .init(isLiked: false, likes: 0, comments: 0, shares: 0) + ) + let state = AppState(feed: FeedState(items: [item.id: item])) + let sut = launch(httpClient: .online(response), store: Store(state: state, mapper: { state, event in + if let event = event as? LikeInteractionEvent { + output.append(event) + } + return state! + })) + + sut.loadViewIfNeeded() + XCTAssertTrue(output.isEmpty) + + let view = sut.feedCardView(at: 0) as? FeedCardView + view?.simulateToggleLikeAction() + + XCTAssertEqual(output.count, 1) + let event = output.payload(at: 0) + + XCTAssertEqual(event.id, item.id) + XCTAssertTrue(event.isLiked) + } } private extension FeedAcceptanceTests { @@ -199,3 +228,9 @@ extension Array where Element == FeedLoadedEvent { return self[index].payload } } + +extension Array where Element == LikeInteractionEvent { + func payload(at index: Int = 0) -> (id: String, isLiked: Bool) { + return self[index].payload + } +} From 60d4d01f8c92398eb348b4c3c04d7e1d25aa67af Mon Sep 17 00:00:00 2001 From: Gordon Smith Date: Tue, 27 Jul 2021 18:39:03 +0100 Subject: [PATCH 26/28] feat: on like interaction event maps payload to state --- Yolo/Helpers/StateContainer.swift | 5 +++++ Yolo/Scenes/Feed/FeedUIComposer.swift | 2 +- YoloTests/Helpers/FeedMapperTests.swift | 10 ++++++++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/Yolo/Helpers/StateContainer.swift b/Yolo/Helpers/StateContainer.swift index e559bef..93fb82f 100644 --- a/Yolo/Helpers/StateContainer.swift +++ b/Yolo/Helpers/StateContainer.swift @@ -75,6 +75,11 @@ public let feedMapper: StateMapper = { state, event in return state } + if let event = event as? LikeInteractionEvent, let item = state.items[event.payload.id] { + state.items[event.payload.id] = event.payload.isLiked ? item.cloneAsLiked() : item.cloneAsUnliked() + return state + } + return state } diff --git a/Yolo/Scenes/Feed/FeedUIComposer.swift b/Yolo/Scenes/Feed/FeedUIComposer.swift index cd5ecc0..14766ee 100644 --- a/Yolo/Scenes/Feed/FeedUIComposer.swift +++ b/Yolo/Scenes/Feed/FeedUIComposer.swift @@ -125,7 +125,7 @@ extension FeedViewAdapter: ResourceView { } } -private extension FeedItem { +extension FeedItem { func clone(with interactions: Interactions) -> Self { FeedItem(id: id, imageURL: imageURL, user: user, interactions: interactions) } diff --git a/YoloTests/Helpers/FeedMapperTests.swift b/YoloTests/Helpers/FeedMapperTests.swift index d52b2d8..c438214 100644 --- a/YoloTests/Helpers/FeedMapperTests.swift +++ b/YoloTests/Helpers/FeedMapperTests.swift @@ -45,6 +45,16 @@ class FeedMapperTests: XCTestCase { XCTAssertEqual(output2.items, [item.id: item]) } + func test_on_like_interaction_event_maps_payload_to_state() { + let item = makeItem() + XCTAssertFalse(item.interactions.isLiked) + + let likeEvent = LikeInteractionEvent(payload: (item.id, true)) + let output = feedMapper(FeedState(items: [item.id: item]), likeEvent) + + let state = output.items[item.id] + XCTAssertEqual(state?.interactions.isLiked, true) + } } private extension FeedMapperTests { From e0f31e95fd0e14c938140609ea0abaca9813395f Mon Sep 17 00:00:00 2001 From: Gordon Smith Date: Tue, 27 Jul 2021 18:50:26 +0100 Subject: [PATCH 27/28] feat: remove local state mutation and handle this as a side effect within the store --- Yolo/Scenes/Feed/FeedUIComposer.swift | 21 +++-------- .../Scenes/Feed/FeedUIIntegrationTests.swift | 35 ------------------- 2 files changed, 4 insertions(+), 52 deletions(-) diff --git a/Yolo/Scenes/Feed/FeedUIComposer.swift b/Yolo/Scenes/Feed/FeedUIComposer.swift index 14766ee..af509fe 100644 --- a/Yolo/Scenes/Feed/FeedUIComposer.swift +++ b/Yolo/Scenes/Feed/FeedUIComposer.swift @@ -61,12 +61,10 @@ extension FeedViewAdapter: ResourceView { typealias ResourceViewModel = FeedViewModel func display(_ viewModel: FeedViewModel) { controller?.display(viewModel.feed.map { item in - - var model = item - + let view = FeedCardCellController() - view.display(FeedCardPresenter.map(model)) + view.display(FeedCardPresenter.map(item)) // MARK:- UserImageView let userImageViewAdapter = ResourcePresentationAdapter>(service: { [imageLoader] in @@ -90,13 +88,8 @@ extension FeedViewAdapter: ResourceView { // MARK:- Interactions let interactionsAdapter = ResourcePresentationAdapter>(service: { [interactionService] in - interactionService(model.id, model.interactions.isLiked ? .unlike : .like) + interactionService(item.id, item.interactions.isLiked ? .unlike : .like) }) - - interactionsAdapter.presenter = ResourcePresenter( - view: ResourceViewAdapter { model = model.clone(with: $0) }, - errorView: ResourceErrorViewAdapter { [weak view] _ in view?.display(FeedCardPresenter.map(item)) } - ) view.onLoadImage = { userImageViewAdapter.execute() @@ -112,13 +105,7 @@ extension FeedViewAdapter: ResourceView { selection(item) } - view.onToggleLikeAction = { [weak view] in - // dispatch request - interactionsAdapter.execute() - // perform optimistic UI update - model = model.toggleLikedState() - view?.display(FeedCardPresenter.map(model)) - } + view.onToggleLikeAction = interactionsAdapter.execute return CellController(id: item, view) }) diff --git a/YoloTests/Scenes/Feed/FeedUIIntegrationTests.swift b/YoloTests/Scenes/Feed/FeedUIIntegrationTests.swift index 8b14440..d8e02cc 100644 --- a/YoloTests/Scenes/Feed/FeedUIIntegrationTests.swift +++ b/YoloTests/Scenes/Feed/FeedUIIntegrationTests.swift @@ -299,41 +299,6 @@ class FeedUIIntegrationTests: XCTestCase { view?.simulateToggleLikeAction() XCTAssertEqual(loader.interactionRequests.count, 1) } - - func test_toggle_like_action_performs_optimistic_state_update() { - let feed = makeFeed() - let (sut, loader) = makeSUT() - sut.loadViewIfNeeded() - loader.loadFeedCompletes(with: .success(feed.items)) - - let view = sut.feedCardView(at: 0) as? FeedCardView - XCTAssertEqual(view?.likesText, "10") - XCTAssertEqual(view?.isShowingAsLiked, true) - - view?.simulateToggleLikeAction() - XCTAssertEqual(view?.likesText, "9") - XCTAssertEqual(view?.isShowingAsLiked, false) - } - - func test_toggle_like_failure_reverts_optimistic_state_update() { - let feed = makeFeed() - let (sut, loader) = makeSUT() - sut.loadViewIfNeeded() - loader.loadFeedCompletes(with: .success(feed.items)) - - let view = sut.feedCardView(at: 0) as? FeedCardView - XCTAssertEqual(view?.likesText, "10") - XCTAssertEqual(view?.isShowingAsLiked, true) - - view?.simulateToggleLikeAction() - XCTAssertEqual(view?.likesText, "9") - XCTAssertEqual(view?.isShowingAsLiked, false) - - loader.toggleInteractionCompletes(with: .failure(makeError())) - - XCTAssertEqual(view?.likesText, "10") - XCTAssertEqual(view?.isShowingAsLiked, true) - } } private extension FeedUIIntegrationTests { From 8e0d59c057e7eff5da32380c6392bd2c7e34616a Mon Sep 17 00:00:00 2001 From: Gordon Smith Date: Tue, 27 Jul 2021 18:57:29 +0100 Subject: [PATCH 28/28] test: improve coverage by asserting both adding and removing a like is handled by the mapper --- YoloTests/Helpers/FeedMapperTests.swift | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/YoloTests/Helpers/FeedMapperTests.swift b/YoloTests/Helpers/FeedMapperTests.swift index c438214..10c0ee7 100644 --- a/YoloTests/Helpers/FeedMapperTests.swift +++ b/YoloTests/Helpers/FeedMapperTests.swift @@ -50,10 +50,16 @@ class FeedMapperTests: XCTestCase { XCTAssertFalse(item.interactions.isLiked) let likeEvent = LikeInteractionEvent(payload: (item.id, true)) - let output = feedMapper(FeedState(items: [item.id: item]), likeEvent) + let output0 = feedMapper(FeedState(items: [item.id: item]), likeEvent) - let state = output.items[item.id] - XCTAssertEqual(state?.interactions.isLiked, true) + let state0 = output0.items[item.id] + XCTAssertEqual(state0?.interactions.isLiked, true) + + let removelikeEvent = LikeInteractionEvent(payload: (item.id, false)) + let output1 = feedMapper(output0, removelikeEvent) + + let state1 = output1.items[item.id] + XCTAssertEqual(state1?.interactions.isLiked, false) } }