diff --git a/Yolo.xcodeproj/project.pbxproj b/Yolo.xcodeproj/project.pbxproj index 0c020a5..9e60724 100644 --- a/Yolo.xcodeproj/project.pbxproj +++ b/Yolo.xcodeproj/project.pbxproj @@ -3,10 +3,18 @@ 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 */; }; + 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 */; }; @@ -101,6 +109,13 @@ /* End PBXContainerItemProxy section */ /* 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 = ""; }; + 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 = ""; }; @@ -200,12 +215,24 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 6A08AA7726AFD04100BA287C /* OrderedCollections in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 6A08AA6E26AF29B800BA287C /* Helpers */ = { + isa = PBXGroup; + children = ( + 6A08AA6F26AF29CA00BA287C /* StateContainerTests.swift */, + 6A08AA7326AF3F9700BA287C /* RootMapperTests.swift */, + 6A08AA7826AFD28D00BA287C /* FeedMapperTests.swift */, + 6A08AA7E26AFE0D400BA287C /* FeedSelectorTests.swift */, + ); + path = Helpers; + sourceTree = ""; + }; 6A137525263BEAFD003F0E5D /* Content */ = { isa = PBXGroup; children = ( @@ -447,6 +474,7 @@ isa = PBXGroup; children = ( 6A30B9C42639EA1700A65598 /* Info.plist */, + 6A08AA6E26AF29B800BA287C /* Helpers */, 6A30B9CE2639EA4C00A65598 /* Modules */, 6A45D7A7263A62B8003EF1C8 /* Scenes */, 6A30B9DC2639EB4300A65598 /* TestHelpers */, @@ -616,6 +644,9 @@ 6AE78CA7263C42F500E350FB /* Interactions+Toggle.swift */, 6A45D7CE263A78CB003EF1C8 /* Publisher+MainQueue.swift */, 6AE02CAE263B1E310089B39F /* ResourcePresentationAdapter.swift */, + 6A08AA7126AF3B9600BA287C /* StateContainer.swift */, + 6A08AA7A26AFDD4B00BA287C /* Memoize.swift */, + 6A08AA7C26AFDE7800BA287C /* Selector.swift */, ); path = Helpers; sourceTree = ""; @@ -843,6 +874,9 @@ dependencies = ( ); name = Yolo; + packageProductDependencies = ( + 6A08AA7626AFD04100BA287C /* OrderedCollections */, + ); productName = Yolo; productReference = 6A79BE792639D918000062A7 /* Yolo.app */; productType = "com.apple.product-type.application"; @@ -875,6 +909,9 @@ Base, ); mainGroup = 6A79BE702639D918000062A7; + packageReferences = ( + 6A08AA7526AFD04100BA287C /* XCRemoteSwiftPackageReference "swift-collections" */, + ); productRefGroup = 6A79BE7A2639D918000062A7 /* Products */; projectDirPath = ""; projectRoot = ""; @@ -915,13 +952,17 @@ 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 */, + 6A08AA7926AFD28D00BA287C /* FeedMapperTests.swift in Sources */, 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 */, + 6A08AA7426AF3F9700BA287C /* RootMapperTests.swift in Sources */, 6A45D837263AE998003EF1C8 /* ImageResponseMapperTests.swift in Sources */, 6A45D7F0263A85B3003EF1C8 /* FeedSnapshotTests.swift in Sources */, 6AC229EC263C2B3D0061718F /* InteractionResposneMapperTests.swift in Sources */, @@ -959,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 */, @@ -983,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 */, @@ -995,6 +1038,7 @@ 6A45D7C0263A6E65003EF1C8 /* FeedUIComposer.swift in Sources */, 6A45D7E5263A7C8A003EF1C8 /* FeedCardCellController.swift in Sources */, 6A26215A263BDE7A00956E14 /* CommentCellController.swift in Sources */, + 6A08AA7226AF3B9600BA287C /* StateContainer.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1252,6 +1296,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 */; } diff --git a/Yolo/Application/SceneDelegate.swift b/Yolo/Application/SceneDelegate.swift index a7c0b01..01afbc8 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) { @@ -81,8 +86,11 @@ private extension SceneDelegate { return httpClient .dispatchPublisher(for: request) .tryMap(FeedResponseMapper.map) - .map { $0.items } - .eraseToAnyPublisher() + .map(\.items) + .handleEvents(receiveOutput: { [store] items in + store.dispatch(FeedLoadedEvent(payload: items)) + }) + .select(from: store, using: feedSelector) } func makeRemoteImageLoader(_ imageURL: URL) -> AnyPublisher { @@ -128,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/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/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..845c841 --- /dev/null +++ b/Yolo/Helpers/Selector.swift @@ -0,0 +1,14 @@ +// +// 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)) + } +} diff --git a/Yolo/Helpers/StateContainer.swift b/Yolo/Helpers/StateContainer.swift new file mode 100644 index 0000000..93fb82f --- /dev/null +++ b/Yolo/Helpers/StateContainer.swift @@ -0,0 +1,101 @@ +// +// StateContainer.swift +// Yolo +// +// Created by Gordon Smith on 26/07/2021. +// + +import Foundation +import Combine +import OrderedCollections + +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 + + 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 { } +} + +public struct AppState: Hashable { + let feed: FeedState + public init(feed: FeedState = .init()) { + self.feed = feed + } +} + +public let rootMapper: StateMapper = { state, event in + AppState( + feed: feedMapper(state?.feed, event) + ) +} + +public struct FeedLoadedEvent: Event { + public let payload: [FeedItem] + public init(payload: [FeedItem]) { + self.payload = payload + } +} + +public struct FeedState: Hashable { + 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 + } + + 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 +} + +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 + } +}) + +public struct LikeInteractionEvent: Event { + public let payload: (id: String, isLiked: Bool) + public init(payload: (id: String, isLiked: Bool)) { + self.payload = payload + } +} diff --git a/Yolo/Scenes/Feed/FeedUIComposer.swift b/Yolo/Scenes/Feed/FeedUIComposer.swift index cd5ecc0..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,20 +105,14 @@ 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) }) } } -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 new file mode 100644 index 0000000..10c0ee7 --- /dev/null +++ b/YoloTests/Helpers/FeedMapperTests.swift @@ -0,0 +1,80 @@ +// +// FeedMapperTests.swift +// YoloTests +// +// Created by Gordon Smith on 27/07/2021. +// + +import XCTest +import Yolo + +class FeedMapperTests: XCTestCase { + + func test_on_init_with_no_state_delivers_default_state() { + struct AnyEvent: Event { } + 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) + } + + 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]) + } + + 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]) + } + + 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 output0 = feedMapper(FeedState(items: [item.id: item]), likeEvent) + + 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) + } +} + +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 + ) + ) + } +} 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 + ) + ) + } +} diff --git a/YoloTests/Helpers/RootMapperTests.swift b/YoloTests/Helpers/RootMapperTests.swift new file mode 100644 index 0000000..17e9273 --- /dev/null +++ b/YoloTests/Helpers/RootMapperTests.swift @@ -0,0 +1,24 @@ +// +// RootMapperTests.swift +// YoloTests +// +// Created by Gordon Smith on 26/07/2021. +// + +import XCTest +import Yolo + +class RootMapperTests: XCTestCase { + func test_on_init_with_no_state_delivers_default_state() { + struct AnyEvent: Event { } + let output = rootMapper(nil, AnyEvent()) + XCTAssertEqual(output, AppState()) + } + + func test_on_init_with_state_delivers_given_state() { + struct AnyEvent: Event { } + let state = AppState() + let output = rootMapper(state, AnyEvent()) + XCTAssertEqual(output, state) + } +} diff --git a/YoloTests/Helpers/StateContainerTests.swift b/YoloTests/Helpers/StateContainerTests.swift new file mode 100644 index 0000000..9efa44e --- /dev/null +++ b/YoloTests/Helpers/StateContainerTests.swift @@ -0,0 +1,59 @@ +// +// StateContainerTests.swift +// YoloTests +// +// Created by Gordon Smith on 26/07/2021. +// + +import XCTest +import Combine +import Yolo + +class StateContainerTests: XCTestCase { + + func test_on_init_emits_initial_state() { + let state = "initial 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) }) + + 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 { } + + 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) + } +} 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 21283e1..69d772d 100644 Binary files a/YoloTests/Modules/Comments/UI/snapshots/COMMENT_WITH_MULTIPLE_LINES_light.png and b/YoloTests/Modules/Comments/UI/snapshots/COMMENT_WITH_MULTIPLE_LINES_light.png differ 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 ddcd874..cf4e1dd 100644 Binary files a/YoloTests/Modules/Feed/UI/snapshots/FEED_WITH_CONTENT_light.png and b/YoloTests/Modules/Feed/UI/snapshots/FEED_WITH_CONTENT_light.png differ diff --git a/YoloTests/Scenes/Feed/FeedAcceptanceTests.swift b/YoloTests/Scenes/Feed/FeedAcceptanceTests.swift index 3573474..9e1edc0 100644 --- a/YoloTests/Scenes/Feed/FeedAcceptanceTests.swift +++ b/YoloTests/Scenes/Feed/FeedAcceptanceTests.swift @@ -42,11 +42,55 @@ 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() + 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 { - func launch(httpClient: HTTPClientStub = .offline) -> ListViewController { - let sut = SceneDelegate(httpClient: httpClient) + 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) @@ -178,3 +222,15 @@ private extension FeedAcceptanceTests { ] as [String : Any] } } + +extension Array where Element == FeedLoadedEvent { + func payload(at index: Int = 0) -> [FeedItem] { + return self[index].payload + } +} + +extension Array where Element == LikeInteractionEvent { + func payload(at index: Int = 0) -> (id: String, isLiked: Bool) { + return self[index].payload + } +} 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 {