From 4aa82fc1a683bd2035fb435e8e3e9f34c3089577 Mon Sep 17 00:00:00 2001 From: Tiago Bras Date: Fri, 28 Oct 2022 17:02:14 +0100 Subject: [PATCH 1/3] feat: add page about clock testing --- MVVM/MVVM.xcodeproj/project.pbxproj | 17 +++ .../xcshareddata/swiftpm/Package.resolved | 13 ++- .../Contents.swift | 101 ++++++++++++++++++ .../Contents.swift | 8 ++ MVVM/mvvm.playground/contents.xcplayground | 1 + 5 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 MVVM/mvvm.playground/Pages/Testing time based events.xcplaygroundpage/Contents.swift 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..4efe69c --- /dev/null +++ b/MVVM/mvvm.playground/Pages/Testing time based events.xcplaygroundpage/Contents.swift @@ -0,0 +1,101 @@ +//: [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 N seconds. +@MainActor +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(1)) + currentIndex = (currentIndex + 1) % cards.count + } + } + } + + func stop() { + task?.cancel() + task = nil + } +} + +// MARK: - Tests - +@MainActor +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 10 seconds pass, it should still show the first element since + // we haven't called started the carousel + await clock.advance(by: .seconds(10)) + XCTAssertEqual(viewModel.currentCard, items[0]) + + // After starting the carousel we should still see the first element + viewModel.start() + await clock.advance(by: .seconds(0.5)) + XCTAssertEqual(viewModel.currentCard, items[0]) + + // But after further 0.6 (1.1s) we should see the second element + await clock.advance(by: .seconds(0.6)) + XCTAssertEqual(viewModel.currentCard, items[1]) + + // 3 seconds later we should see the last element + await clock.advance(by: .seconds(3)) + XCTAssertEqual(viewModel.currentCard, items[4]) + + // 1 second later it should go back to the first element + await clock.advance(by: .seconds(2)) + XCTAssertEqual(viewModel.currentCard, items[1]) + + // After we stop the carousel, it shouldn't update + viewModel.stop() + await clock.advance(by: .seconds(3)) + XCTAssertEqual(viewModel.currentCard, items[1]) + await clock.advance(by: .seconds(1)) + 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(1.3)) + XCTAssertEqual(viewModel.currentCard, items[2]) + } +} + +// And run the tests +OnboardingViewModelTestCase.defaultTestSuite.run() + +//: [Next](@next) + + + diff --git a/MVVM/mvvm.playground/Pages/[WIP]Testing VIPER, easy edition.xcplaygroundpage/Contents.swift b/MVVM/mvvm.playground/Pages/[WIP]Testing VIPER, easy edition.xcplaygroundpage/Contents.swift index 5275afb..99bfa1f 100644 --- a/MVVM/mvvm.playground/Pages/[WIP]Testing VIPER, easy edition.xcplaygroundpage/Contents.swift +++ b/MVVM/mvvm.playground/Pages/[WIP]Testing VIPER, easy edition.xcplaygroundpage/Contents.swift @@ -4,4 +4,12 @@ import Foundation var greeting = "Hello, playground" +Task { + let c1 = ContinuousClock() + let d1 = Date() + + print(c1.now) + print(d1.timeIntervalSince1970) +} + //: [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 From 8cd7d8c5f35cf303ab31b5fd1022ba41984db006 Mon Sep 17 00:00:00 2001 From: Tiago Bras Date: Mon, 31 Oct 2022 09:35:08 +0000 Subject: [PATCH 2/3] fix: update comments --- .../Contents.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 index 4efe69c..fb111fa 100644 --- a/MVVM/mvvm.playground/Pages/Testing time based events.xcplaygroundpage/Contents.swift +++ b/MVVM/mvvm.playground/Pages/Testing time based events.xcplaygroundpage/Contents.swift @@ -55,16 +55,16 @@ class OnboardingViewModelTestCase: XCTestCase { XCTAssertEqual(viewModel.currentCard, items[0]) // Even if 10 seconds pass, it should still show the first element since - // we haven't called started the carousel + // we haven't started the carousel await clock.advance(by: .seconds(10)) XCTAssertEqual(viewModel.currentCard, items[0]) - // After starting the carousel we should still see the first element + // After 0.5s the carousel we should still see the 1st element viewModel.start() await clock.advance(by: .seconds(0.5)) XCTAssertEqual(viewModel.currentCard, items[0]) - // But after further 0.6 (1.1s) we should see the second element + // But after further 0.6 (1.1s) we should see the 2nd element await clock.advance(by: .seconds(0.6)) XCTAssertEqual(viewModel.currentCard, items[1]) @@ -72,11 +72,11 @@ class OnboardingViewModelTestCase: XCTestCase { await clock.advance(by: .seconds(3)) XCTAssertEqual(viewModel.currentCard, items[4]) - // 1 second later it should go back to the first element + // 2 seconds later it should go back to the 2nd element await clock.advance(by: .seconds(2)) XCTAssertEqual(viewModel.currentCard, items[1]) - // After we stop the carousel, it shouldn't update + // After we stop the carousel, it should still show the 2nd element viewModel.stop() await clock.advance(by: .seconds(3)) XCTAssertEqual(viewModel.currentCard, items[1]) From c312fc033fd6bb6d41c6aa28f506d505d183c2b4 Mon Sep 17 00:00:00 2001 From: Tiago Bras Date: Mon, 31 Oct 2022 12:11:40 +0000 Subject: [PATCH 3/3] feat: add an example of ImmediateClocks --- .../Contents.swift | 89 +++++++++++++++---- .../Contents.swift | 8 -- 2 files changed, 71 insertions(+), 26 deletions(-) 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 index fb111fa..d65c666 100644 --- a/MVVM/mvvm.playground/Pages/Testing time based events.xcplaygroundpage/Contents.swift +++ b/MVVM/mvvm.playground/Pages/Testing time based events.xcplaygroundpage/Contents.swift @@ -9,8 +9,7 @@ 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 N seconds. -@MainActor +/// automatically shows the next view after a small delay of 5 seconds. class OnboardingViewModel: ObservableObject { @Published var cards: [String] @Published var currentIndex: Int @@ -31,7 +30,7 @@ class OnboardingViewModel: ObservableObject { func start() { task = Task { while true { - try await clock.sleep(for: .seconds(1)) + try await clock.sleep(for: .seconds(5)) currentIndex = (currentIndex + 1) % cards.count } } @@ -44,7 +43,6 @@ class OnboardingViewModel: ObservableObject { } // MARK: - Tests - -@MainActor class OnboardingViewModelTestCase: XCTestCase { func testCarousel() async { let items = ["One", "Two", "Three", "Four", "Five"] @@ -54,40 +52,40 @@ class OnboardingViewModelTestCase: XCTestCase { // When the view model gets initialized it should show the first element XCTAssertEqual(viewModel.currentCard, items[0]) - // Even if 10 seconds pass, it should still show the first element since + // Even if 50 seconds pass, it should still show the first element since // we haven't started the carousel - await clock.advance(by: .seconds(10)) + await clock.advance(by: .seconds(50)) XCTAssertEqual(viewModel.currentCard, items[0]) - // After 0.5s the carousel we should still see the 1st element + // After 2.5s the carousel we should still see the 1st element viewModel.start() - await clock.advance(by: .seconds(0.5)) + await clock.advance(by: .seconds(2.5)) XCTAssertEqual(viewModel.currentCard, items[0]) - // But after further 0.6 (1.1s) we should see the 2nd element - await clock.advance(by: .seconds(0.6)) + // 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]) - // 3 seconds later we should see the last element - await clock.advance(by: .seconds(3)) + // 15 seconds later we should see the last element + await clock.advance(by: .seconds(15)) XCTAssertEqual(viewModel.currentCard, items[4]) - // 2 seconds later it should go back to the 2nd element - await clock.advance(by: .seconds(2)) + // 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(3)) + await clock.advance(by: .seconds(15)) XCTAssertEqual(viewModel.currentCard, items[1]) - await clock.advance(by: .seconds(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(1.3)) + await clock.advance(by: .seconds(6.5)) XCTAssertEqual(viewModel.currentCard, items[2]) } } @@ -95,7 +93,62 @@ class OnboardingViewModelTestCase: XCTestCase { // And run the tests OnboardingViewModelTestCase.defaultTestSuite.run() -//: [Next](@next) +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/Pages/[WIP]Testing VIPER, easy edition.xcplaygroundpage/Contents.swift b/MVVM/mvvm.playground/Pages/[WIP]Testing VIPER, easy edition.xcplaygroundpage/Contents.swift index 99bfa1f..5275afb 100644 --- a/MVVM/mvvm.playground/Pages/[WIP]Testing VIPER, easy edition.xcplaygroundpage/Contents.swift +++ b/MVVM/mvvm.playground/Pages/[WIP]Testing VIPER, easy edition.xcplaygroundpage/Contents.swift @@ -4,12 +4,4 @@ import Foundation var greeting = "Hello, playground" -Task { - let c1 = ContinuousClock() - let d1 = Date() - - print(c1.now) - print(d1.timeIntervalSince1970) -} - //: [Next](@next)