diff --git a/MVVM/MVVM.xcodeproj/project.pbxproj b/MVVM/MVVM.xcodeproj/project.pbxproj index e2fcd1f..74ba8b4 100644 --- a/MVVM/MVVM.xcodeproj/project.pbxproj +++ b/MVVM/MVVM.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + A569D7F1290BF6040053A48F /* Clocks in Frameworks */ = {isa = PBXBuildFile; productRef = A569D7F0290BF6040053A48F /* Clocks */; }; BD43014B27CFA04100EC0A07 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD43014A27CFA04100EC0A07 /* AppDelegate.swift */; }; BDC59D3827A839320054A19B /* CombineSchedulers in Frameworks */ = {isa = PBXBuildFile; productRef = BDC59D3727A839320054A19B /* CombineSchedulers */; }; BDF647B827BE381100EF94EF /* XCTestDynamicOverlay in Frameworks */ = {isa = PBXBuildFile; productRef = BDF647B727BE381100EF94EF /* XCTestDynamicOverlay */; }; @@ -26,6 +27,7 @@ files = ( BDF647B827BE381100EF94EF /* XCTestDynamicOverlay in Frameworks */, BDC59D3827A839320054A19B /* CombineSchedulers in Frameworks */, + A569D7F1290BF6040053A48F /* Clocks in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -77,6 +79,7 @@ packageProductDependencies = ( BDC59D3727A839320054A19B /* CombineSchedulers */, BDF647B727BE381100EF94EF /* XCTestDynamicOverlay */, + A569D7F0290BF6040053A48F /* Clocks */, ); productName = MVVM; productReference = BDC59D1F27A82C820054A19B /* MVVM.app */; @@ -110,6 +113,7 @@ packageReferences = ( BDC59D3627A839320054A19B /* XCRemoteSwiftPackageReference "combine-schedulers" */, BDF647B627BE381100EF94EF /* XCRemoteSwiftPackageReference "xctest-dynamic-overlay" */, + A569D7EF290BF6040053A48F /* XCRemoteSwiftPackageReference "swift-clocks" */, ); productRefGroup = BDC59D2027A82C820054A19B /* Products */; projectDirPath = ""; @@ -339,6 +343,14 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ + A569D7EF290BF6040053A48F /* XCRemoteSwiftPackageReference "swift-clocks" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/pointfreeco/swift-clocks"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.1.4; + }; + }; BDC59D3627A839320054A19B /* XCRemoteSwiftPackageReference "combine-schedulers" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/pointfreeco/combine-schedulers.git"; @@ -358,6 +370,11 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + A569D7F0290BF6040053A48F /* Clocks */ = { + isa = XCSwiftPackageProductDependency; + package = A569D7EF290BF6040053A48F /* XCRemoteSwiftPackageReference "swift-clocks" */; + productName = Clocks; + }; BDC59D3727A839320054A19B /* CombineSchedulers */ = { isa = XCSwiftPackageProductDependency; package = BDC59D3627A839320054A19B /* XCRemoteSwiftPackageReference "combine-schedulers" */; diff --git a/MVVM/MVVM.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/MVVM/MVVM.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index cc1f7b9..7ea91a1 100644 --- a/MVVM/MVVM.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/MVVM/MVVM.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -9,13 +9,22 @@ "version" : "0.5.3" } }, + { + "identity" : "swift-clocks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-clocks", + "state" : { + "revision" : "692ec4f5429a667bdd968c7260dfa2b23adfeffc", + "version" : "0.1.4" + } + }, { "identity" : "xctest-dynamic-overlay", "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", "state" : { - "revision" : "50a70a9d3583fe228ce672e8923010c8df2deddd", - "version" : "0.2.1" + "revision" : "16e6409ee82e1b81390bdffbf217b9c08ab32784", + "version" : "0.5.0" } } ], diff --git a/MVVM/mvvm.playground/Pages/Testing time based events.xcplaygroundpage/Contents.swift b/MVVM/mvvm.playground/Pages/Testing time based events.xcplaygroundpage/Contents.swift new file mode 100644 index 0000000..d65c666 --- /dev/null +++ b/MVVM/mvvm.playground/Pages/Testing time based events.xcplaygroundpage/Contents.swift @@ -0,0 +1,154 @@ +//: [Previous](@previous) + +import Combine +import CombineSchedulers +import Foundation +import PlaygroundSupport +import SwiftUI +import XCTest +import Clocks + +/// Let's suppose we have an Onboarding screen with a carousel that +/// automatically shows the next view after a small delay of 5 seconds. +class OnboardingViewModel: ObservableObject { + @Published var cards: [String] + @Published var currentIndex: Int + private let clock: any Clock + private var task: Task? + + var currentCard: String? { + guard currentIndex < cards.count else { return nil } + return cards[currentIndex] + } + + init(items: [String], clock: any Clock) { + self.cards = items + self.currentIndex = 0 + self.clock = clock + } + + func start() { + task = Task { + while true { + try await clock.sleep(for: .seconds(5)) + currentIndex = (currentIndex + 1) % cards.count + } + } + } + + func stop() { + task?.cancel() + task = nil + } +} + +// MARK: - Tests - +class OnboardingViewModelTestCase: XCTestCase { + func testCarousel() async { + let items = ["One", "Two", "Three", "Four", "Five"] + let clock = TestClock() + let viewModel = OnboardingViewModel(items: items, clock: clock) + + // When the view model gets initialized it should show the first element + XCTAssertEqual(viewModel.currentCard, items[0]) + + // Even if 50 seconds pass, it should still show the first element since + // we haven't started the carousel + await clock.advance(by: .seconds(50)) + XCTAssertEqual(viewModel.currentCard, items[0]) + + // After 2.5s the carousel we should still see the 1st element + viewModel.start() + await clock.advance(by: .seconds(2.5)) + XCTAssertEqual(viewModel.currentCard, items[0]) + + // But after further 2.6 (5.1s) we should see the 2nd element + await clock.advance(by: .seconds(2.6)) + XCTAssertEqual(viewModel.currentCard, items[1]) + + // 15 seconds later we should see the last element + await clock.advance(by: .seconds(15)) + XCTAssertEqual(viewModel.currentCard, items[4]) + + // 10 seconds later it should go back to the 2nd element + await clock.advance(by: .seconds(10)) + XCTAssertEqual(viewModel.currentCard, items[1]) + + // After we stop the carousel, it should still show the 2nd element + viewModel.stop() + await clock.advance(by: .seconds(15)) + XCTAssertEqual(viewModel.currentCard, items[1]) + await clock.advance(by: .seconds(5)) + XCTAssertEqual(viewModel.currentCard, items[1]) + + // After resuming the carousel it should show the same element as before + viewModel.start() + + XCTAssertEqual(viewModel.currentCard, items[1]) + await clock.advance(by: .seconds(6.5)) + XCTAssertEqual(viewModel.currentCard, items[2]) + } +} + +// And run the tests +OnboardingViewModelTestCase.defaultTestSuite.run() + +class FeatureViewModel: ObservableObject { + @Published var isButtonDisabled = true + @Published var showTopView = false + private let clock: any Clock + + init(clock: any Clock) { + self.clock = clock + } + + @Sendable func onViewAppear() async { + do { + try await clock.sleep(for: .seconds(3)) + isButtonDisabled = false + } catch { print(error) } + } +} + +// Here we have a view that has a button that is disabled +// for the first 3 seconds. Maybe we want to show the user +// a short video before allowing them to click the button. +struct FeatureView: View { + @ObservedObject var viewModel: FeatureViewModel + + var body: some View { + VStack { + if viewModel.showTopView { + Image(systemName: "car") + .resizable() + .scaledToFit() + .frame(width: 50, height: 50) + } + + Spacer() + Button { + viewModel.showTopView.toggle() + } label: { + Text(viewModel.showTopView ? "Hide" : "Show") + } + .disabled(viewModel.isButtonDisabled) + } + .padding(40) + .task(viewModel.onViewAppear) + } +} + +// If we want to quickly iterate on the design of this view with previews, +// using a ContinuousClock() we would have to wait 3 seconds before +// being able to click the button. To solve this issue we can use +// ImmediateClocks. This type of clock "removes" all delays when sleeping tasks. +// This can also be use in unit testing but TestClock is more useful. +PlaygroundPage.current.setLiveView( + FeatureView( + viewModel: FeatureViewModel(clock: ImmediateClock()) +// viewModel: FeatureViewModel(clock: ContinuousClock()) + ) + .frame(width: 200, height: 200) +) + +//: [Next](@next) diff --git a/MVVM/mvvm.playground/contents.xcplayground b/MVVM/mvvm.playground/contents.xcplayground index d5fba92..cf042f1 100644 --- a/MVVM/mvvm.playground/contents.xcplayground +++ b/MVVM/mvvm.playground/contents.xcplayground @@ -7,5 +7,6 @@ + \ No newline at end of file