diff --git a/0242-reliably-testing-async-pt5/README.md b/0242-reliably-testing-async-pt5/README.md new file mode 100644 index 00000000..e9de07d3 --- /dev/null +++ b/0242-reliably-testing-async-pt5/README.md @@ -0,0 +1,5 @@ +## [Point-Free](https://www.pointfree.co) + +> #### This directory contains code from Point-Free Episode: [Reliable Async Tests: The Point](https://www.pointfree.co/episodes/ep242-reliable-async-tests-the-point) +> +> What’s the point of the work we did to make async testing reliable and deterministic, and are we even testing reality anymore? We conclude our series by rewriting our feature and tests using Combine instead of async-await, and comparing both approaches. diff --git a/0242-reliably-testing-async-pt5/ReliablyTestingAsync/ReliablyTestingAsync.xcodeproj/project.pbxproj b/0242-reliably-testing-async-pt5/ReliablyTestingAsync/ReliablyTestingAsync.xcodeproj/project.pbxproj new file mode 100644 index 00000000..797d66ac --- /dev/null +++ b/0242-reliably-testing-async-pt5/ReliablyTestingAsync/ReliablyTestingAsync.xcodeproj/project.pbxproj @@ -0,0 +1,521 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + 2A02A49F2A3100350073BDEC /* LockIsolated.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A02A49E2A3100350073BDEC /* LockIsolated.swift */; }; + 4B07DB1E2A31063800037088 /* NumberFactModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B07DB1D2A31063800037088 /* NumberFactModelTests.swift */; }; + 4B07DB212A31082B00037088 /* Dependencies in Frameworks */ = {isa = PBXBuildFile; productRef = 4B07DB202A31082B00037088 /* Dependencies */; }; + 4B5108832A312ACE0092F1D9 /* Countdown.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B5108822A312ACE0092F1D9 /* Countdown.swift */; }; + 4B5108852A3133C20092F1D9 /* ConcurrencyExtras.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B5108842A3133C20092F1D9 /* ConcurrencyExtras.swift */; }; + CAC2DD812A30B827007675BD /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAC2DD802A30B827007675BD /* App.swift */; }; + CAC2DD832A30B827007675BD /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAC2DD822A30B827007675BD /* ContentView.swift */; }; + CAC2DD852A30B829007675BD /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CAC2DD842A30B829007675BD /* Assets.xcassets */; }; + CAC2DD882A30B829007675BD /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CAC2DD872A30B829007675BD /* Preview Assets.xcassets */; }; + CAC2DD922A30B829007675BD /* ReliablyTestingAsyncTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAC2DD912A30B829007675BD /* ReliablyTestingAsyncTests.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + CAC2DD8E2A30B829007675BD /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = CAC2DD752A30B827007675BD /* Project object */; + proxyType = 1; + remoteGlobalIDString = CAC2DD7C2A30B827007675BD; + remoteInfo = ReliablyTestingAsync; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 2A02A49D2A30FD3E0073BDEC /* ReliablyTestingAsync.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = ReliablyTestingAsync.xctestplan; sourceTree = ""; }; + 2A02A49E2A3100350073BDEC /* LockIsolated.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LockIsolated.swift; sourceTree = ""; }; + 4B07DB1D2A31063800037088 /* NumberFactModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NumberFactModelTests.swift; sourceTree = ""; }; + 4B07DB222A310B1300037088 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 4B5108822A312ACE0092F1D9 /* Countdown.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Countdown.swift; sourceTree = ""; }; + 4B5108842A3133C20092F1D9 /* ConcurrencyExtras.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConcurrencyExtras.swift; sourceTree = ""; }; + CAC2DD7D2A30B827007675BD /* ReliablyTestingAsync.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ReliablyTestingAsync.app; sourceTree = BUILT_PRODUCTS_DIR; }; + CAC2DD802A30B827007675BD /* App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = ""; }; + CAC2DD822A30B827007675BD /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + CAC2DD842A30B829007675BD /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + CAC2DD872A30B829007675BD /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; + CAC2DD8D2A30B829007675BD /* ReliablyTestingAsyncTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ReliablyTestingAsyncTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + CAC2DD912A30B829007675BD /* ReliablyTestingAsyncTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReliablyTestingAsyncTests.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + CAC2DD7A2A30B827007675BD /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 4B07DB212A31082B00037088 /* Dependencies in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + CAC2DD8A2A30B829007675BD /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + CAC2DD742A30B827007675BD = { + isa = PBXGroup; + children = ( + CAC2DD7F2A30B827007675BD /* ReliablyTestingAsync */, + CAC2DD902A30B829007675BD /* ReliablyTestingAsyncTests */, + CAC2DD7E2A30B827007675BD /* Products */, + ); + sourceTree = ""; + }; + CAC2DD7E2A30B827007675BD /* Products */ = { + isa = PBXGroup; + children = ( + CAC2DD7D2A30B827007675BD /* ReliablyTestingAsync.app */, + CAC2DD8D2A30B829007675BD /* ReliablyTestingAsyncTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + CAC2DD7F2A30B827007675BD /* ReliablyTestingAsync */ = { + isa = PBXGroup; + children = ( + 4B07DB222A310B1300037088 /* Info.plist */, + CAC2DD802A30B827007675BD /* App.swift */, + 4B5108842A3133C20092F1D9 /* ConcurrencyExtras.swift */, + CAC2DD822A30B827007675BD /* ContentView.swift */, + 4B5108822A312ACE0092F1D9 /* Countdown.swift */, + CAC2DD842A30B829007675BD /* Assets.xcassets */, + CAC2DD862A30B829007675BD /* Preview Content */, + ); + path = ReliablyTestingAsync; + sourceTree = ""; + }; + CAC2DD862A30B829007675BD /* Preview Content */ = { + isa = PBXGroup; + children = ( + CAC2DD872A30B829007675BD /* Preview Assets.xcassets */, + ); + path = "Preview Content"; + sourceTree = ""; + }; + CAC2DD902A30B829007675BD /* ReliablyTestingAsyncTests */ = { + isa = PBXGroup; + children = ( + 2A02A49D2A30FD3E0073BDEC /* ReliablyTestingAsync.xctestplan */, + CAC2DD912A30B829007675BD /* ReliablyTestingAsyncTests.swift */, + 4B07DB1D2A31063800037088 /* NumberFactModelTests.swift */, + 2A02A49E2A3100350073BDEC /* LockIsolated.swift */, + ); + path = ReliablyTestingAsyncTests; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + CAC2DD7C2A30B827007675BD /* ReliablyTestingAsync */ = { + isa = PBXNativeTarget; + buildConfigurationList = CAC2DDA12A30B829007675BD /* Build configuration list for PBXNativeTarget "ReliablyTestingAsync" */; + buildPhases = ( + CAC2DD792A30B827007675BD /* Sources */, + CAC2DD7A2A30B827007675BD /* Frameworks */, + CAC2DD7B2A30B827007675BD /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = ReliablyTestingAsync; + packageProductDependencies = ( + 4B07DB202A31082B00037088 /* Dependencies */, + ); + productName = ReliablyTestingAsync; + productReference = CAC2DD7D2A30B827007675BD /* ReliablyTestingAsync.app */; + productType = "com.apple.product-type.application"; + }; + CAC2DD8C2A30B829007675BD /* ReliablyTestingAsyncTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = CAC2DDA42A30B829007675BD /* Build configuration list for PBXNativeTarget "ReliablyTestingAsyncTests" */; + buildPhases = ( + CAC2DD892A30B829007675BD /* Sources */, + CAC2DD8A2A30B829007675BD /* Frameworks */, + CAC2DD8B2A30B829007675BD /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + CAC2DD8F2A30B829007675BD /* PBXTargetDependency */, + ); + name = ReliablyTestingAsyncTests; + productName = ReliablyTestingAsyncTests; + productReference = CAC2DD8D2A30B829007675BD /* ReliablyTestingAsyncTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + CAC2DD752A30B827007675BD /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1500; + LastUpgradeCheck = 1500; + TargetAttributes = { + CAC2DD7C2A30B827007675BD = { + CreatedOnToolsVersion = 15.0; + }; + CAC2DD8C2A30B829007675BD = { + CreatedOnToolsVersion = 15.0; + TestTargetID = CAC2DD7C2A30B827007675BD; + }; + }; + }; + buildConfigurationList = CAC2DD782A30B827007675BD /* Build configuration list for PBXProject "ReliablyTestingAsync" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = CAC2DD742A30B827007675BD; + packageReferences = ( + 4B07DB1F2A31082B00037088 /* XCRemoteSwiftPackageReference "swift-dependencies" */, + ); + productRefGroup = CAC2DD7E2A30B827007675BD /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + CAC2DD7C2A30B827007675BD /* ReliablyTestingAsync */, + CAC2DD8C2A30B829007675BD /* ReliablyTestingAsyncTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + CAC2DD7B2A30B827007675BD /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + CAC2DD882A30B829007675BD /* Preview Assets.xcassets in Resources */, + CAC2DD852A30B829007675BD /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + CAC2DD8B2A30B829007675BD /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + CAC2DD792A30B827007675BD /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 4B5108852A3133C20092F1D9 /* ConcurrencyExtras.swift in Sources */, + CAC2DD832A30B827007675BD /* ContentView.swift in Sources */, + CAC2DD812A30B827007675BD /* App.swift in Sources */, + 4B5108832A312ACE0092F1D9 /* Countdown.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + CAC2DD892A30B829007675BD /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + CAC2DD922A30B829007675BD /* ReliablyTestingAsyncTests.swift in Sources */, + 4B07DB1E2A31063800037088 /* NumberFactModelTests.swift in Sources */, + 2A02A49F2A3100350073BDEC /* LockIsolated.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + CAC2DD8F2A30B829007675BD /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = CAC2DD7C2A30B827007675BD /* ReliablyTestingAsync */; + targetProxy = CAC2DD8E2A30B829007675BD /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + CAC2DD9F2A30B829007675BD /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_STRICT_CONCURRENCY = complete; + }; + name = Debug; + }; + CAC2DDA02A30B829007675BD /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_STRICT_CONCURRENCY = complete; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + CAC2DDA22A30B829007675BD /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"ReliablyTestingAsync/Preview Content\""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = ReliablyTestingAsync/Info.plist; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.ReliablyTestingAsync; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + CAC2DDA32A30B829007675BD /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"ReliablyTestingAsync/Preview Content\""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = ReliablyTestingAsync/Info.plist; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.ReliablyTestingAsync; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + CAC2DDA52A30B829007675BD /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.ReliablyTestingAsyncTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/ReliablyTestingAsync.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/ReliablyTestingAsync"; + }; + name = Debug; + }; + CAC2DDA62A30B829007675BD /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.ReliablyTestingAsyncTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/ReliablyTestingAsync.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/ReliablyTestingAsync"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + CAC2DD782A30B827007675BD /* Build configuration list for PBXProject "ReliablyTestingAsync" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + CAC2DD9F2A30B829007675BD /* Debug */, + CAC2DDA02A30B829007675BD /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + CAC2DDA12A30B829007675BD /* Build configuration list for PBXNativeTarget "ReliablyTestingAsync" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + CAC2DDA22A30B829007675BD /* Debug */, + CAC2DDA32A30B829007675BD /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + CAC2DDA42A30B829007675BD /* Build configuration list for PBXNativeTarget "ReliablyTestingAsyncTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + CAC2DDA52A30B829007675BD /* Debug */, + CAC2DDA62A30B829007675BD /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 4B07DB1F2A31082B00037088 /* XCRemoteSwiftPackageReference "swift-dependencies" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/pointfreeco/swift-dependencies.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.5.1; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 4B07DB202A31082B00037088 /* Dependencies */ = { + isa = XCSwiftPackageProductDependency; + package = 4B07DB1F2A31082B00037088 /* XCRemoteSwiftPackageReference "swift-dependencies" */; + productName = Dependencies; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = CAC2DD752A30B827007675BD /* Project object */; +} diff --git a/0242-reliably-testing-async-pt5/ReliablyTestingAsync/ReliablyTestingAsync.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/0242-reliably-testing-async-pt5/ReliablyTestingAsync/ReliablyTestingAsync.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/0242-reliably-testing-async-pt5/ReliablyTestingAsync/ReliablyTestingAsync.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/0242-reliably-testing-async-pt5/ReliablyTestingAsync/ReliablyTestingAsync.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/0242-reliably-testing-async-pt5/ReliablyTestingAsync/ReliablyTestingAsync.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/0242-reliably-testing-async-pt5/ReliablyTestingAsync/ReliablyTestingAsync.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/0242-reliably-testing-async-pt5/ReliablyTestingAsync/ReliablyTestingAsync.xcodeproj/xcshareddata/xcschemes/ReliablyTestingAsync.xcscheme b/0242-reliably-testing-async-pt5/ReliablyTestingAsync/ReliablyTestingAsync.xcodeproj/xcshareddata/xcschemes/ReliablyTestingAsync.xcscheme new file mode 100644 index 00000000..a0f9a012 --- /dev/null +++ b/0242-reliably-testing-async-pt5/ReliablyTestingAsync/ReliablyTestingAsync.xcodeproj/xcshareddata/xcschemes/ReliablyTestingAsync.xcscheme @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/0242-reliably-testing-async-pt5/ReliablyTestingAsync/ReliablyTestingAsync/App.swift b/0242-reliably-testing-async-pt5/ReliablyTestingAsync/ReliablyTestingAsync/App.swift new file mode 100644 index 00000000..5d8e4f62 --- /dev/null +++ b/0242-reliably-testing-async-pt5/ReliablyTestingAsync/ReliablyTestingAsync/App.swift @@ -0,0 +1,12 @@ +import SwiftUI + +@main +struct ReliablyTestingAsyncApp: App { + var body: some Scene { + WindowGroup { + if NSClassFromString("XCTestCase") == nil { + ContentView(model: CombineNumberFactModel()) + } + } + } +} diff --git a/0242-reliably-testing-async-pt5/ReliablyTestingAsync/ReliablyTestingAsync/Assets.xcassets/AccentColor.colorset/Contents.json b/0242-reliably-testing-async-pt5/ReliablyTestingAsync/ReliablyTestingAsync/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000..eb878970 --- /dev/null +++ b/0242-reliably-testing-async-pt5/ReliablyTestingAsync/ReliablyTestingAsync/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/0242-reliably-testing-async-pt5/ReliablyTestingAsync/ReliablyTestingAsync/Assets.xcassets/AppIcon.appiconset/Contents.json b/0242-reliably-testing-async-pt5/ReliablyTestingAsync/ReliablyTestingAsync/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..13613e3e --- /dev/null +++ b/0242-reliably-testing-async-pt5/ReliablyTestingAsync/ReliablyTestingAsync/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/0242-reliably-testing-async-pt5/ReliablyTestingAsync/ReliablyTestingAsync/Assets.xcassets/Contents.json b/0242-reliably-testing-async-pt5/ReliablyTestingAsync/ReliablyTestingAsync/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/0242-reliably-testing-async-pt5/ReliablyTestingAsync/ReliablyTestingAsync/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/0242-reliably-testing-async-pt5/ReliablyTestingAsync/ReliablyTestingAsync/ConcurrencyExtras.swift b/0242-reliably-testing-async-pt5/ReliablyTestingAsync/ReliablyTestingAsync/ConcurrencyExtras.swift new file mode 100644 index 00000000..5fb53f50 --- /dev/null +++ b/0242-reliably-testing-async-pt5/ReliablyTestingAsync/ReliablyTestingAsync/ConcurrencyExtras.swift @@ -0,0 +1,13 @@ +import Darwin + +typealias Original = @convention(thin) (UnownedJob) -> Void +typealias Hook = @convention(thin) (UnownedJob, Original) -> Void + +var swift_task_enqueueGlobal_hook: Hook? { + get { _swift_task_enqueueGlobal_hook.pointee } + set { _swift_task_enqueueGlobal_hook.pointee = newValue } +} + +private let _swift_task_enqueueGlobal_hook = + dlsym(dlopen(nil, RTLD_LAZY), "swift_task_enqueueGlobal_hook") + .assumingMemoryBound(to: Hook?.self) diff --git a/0242-reliably-testing-async-pt5/ReliablyTestingAsync/ReliablyTestingAsync/ContentView.swift b/0242-reliably-testing-async-pt5/ReliablyTestingAsync/ReliablyTestingAsync/ContentView.swift new file mode 100644 index 00000000..fb1a9c75 --- /dev/null +++ b/0242-reliably-testing-async-pt5/ReliablyTestingAsync/ReliablyTestingAsync/ContentView.swift @@ -0,0 +1,172 @@ +import Combine +import Dependencies +import SwiftUI + +struct NumberFactClient { + var fact: @Sendable (Int) async throws -> String + var factPublisher: @Sendable (Int) -> AnyPublisher +} + +extension NumberFactClient: DependencyKey { + static let liveValue = Self { number in + try await Task.sleep(for: .seconds(1)) + return try await String( + decoding: URLSession.shared.data(from: URL(string: "http://numbersapi.com/\(number)")!).0, + as: UTF8.self + ) + } factPublisher: { number in + URLSession.shared.dataTaskPublisher(for: URL(string: "http://numbersapi.com/\(number)")!) +// .delay(for: 1, scheduler: DispatchQueue.main) + .map { data, _ in String(decoding: data, as: UTF8.self) } + .mapError { $0 as Error } + .eraseToAnyPublisher() + } +} + +extension DependencyValues { + var numberFact: NumberFactClient { + get { self[NumberFactClient.self] } + set { self[NumberFactClient.self] = newValue } + } +} + +@MainActor +class CombineNumberFactModel: ObservableObject { + @Dependency(\.mainQueue) var mainQueue + @Dependency(\.numberFact) var numberFact + + @Published var count = 0 + @Published var fact: String? + @Published var factCancellable: AnyCancellable? + var isLoading: Bool { self.factCancellable != nil } + + func incrementButtonTapped() { + self.fact = nil + self.factCancellable?.cancel() + self.factCancellable = nil + self.count += 1 + } + func decrementButtonTapped() { + self.fact = nil + self.factCancellable?.cancel() + self.factCancellable = nil + self.count -= 1 + } + func getFactButtonTapped() { + self.factCancellable?.cancel() + + self.fact = nil + self.factCancellable = self.numberFact.factPublisher(self.count) + .receive(on: self.mainQueue) + .sink( + receiveCompletion: { [weak self] _ in + // TODO: Handle error + self?.factCancellable = nil + }, + receiveValue: { [weak self] fact in + self?.fact = fact + } + ) + } + func cancelButtonTapped() { + self.factCancellable?.cancel() + self.factCancellable = nil + } + var notificationCancellable: AnyCancellable? + func onTask() { + self.notificationCancellable = NotificationCenter.default.publisher(for: UIApplication.userDidTakeScreenshotNotification) + .sink { [weak self] _ in + self?.count += 1 + } + } +} +@MainActor +class NumberFactModel: ObservableObject { + @Dependency(\.numberFact) var numberFact + + @Published var count = 0 + @Published var fact: String? + @Published var factTask: Task? + var isLoading: Bool { self.factTask != nil } + + func incrementButtonTapped() { + self.fact = nil + self.factTask?.cancel() + self.factTask = nil + self.count += 1 + } + func decrementButtonTapped() { + self.fact = nil + self.factTask?.cancel() + self.factTask = nil + self.count -= 1 + } + func getFactButtonTapped() async { + self.factTask?.cancel() + + self.fact = nil + self.factTask = Task { + try await self.numberFact.fact(self.count) + } + defer { self.factTask = nil } + do { + self.fact = try await self.factTask?.value + } catch { + // TODO: handle error + } + } + func cancelButtonTapped() { + self.factTask?.cancel() + self.factTask = nil + } + func onTask() async { + for await _ in NotificationCenter.default.notifications(named: UIApplication.userDidTakeScreenshotNotification) { + self.count += 1 + } + } +} + +struct ContentView: View { + @ObservedObject var model: CombineNumberFactModel + + var body: some View { + Form { + Section { + HStack { + Button("-") { self.model.decrementButtonTapped() } + Text("\(self.model.count)") + Button("+") { self.model.incrementButtonTapped() } + } + } + .buttonStyle(.plain) + + Section { + if self.model.isLoading { + HStack(spacing: 4) { + Button("Cancel") { + self.model.cancelButtonTapped() + } + Spacer() + ProgressView() + .id(UUID()) + } + } else { + Button("Get fact") { + Task { await self.model.getFactButtonTapped() } + } + } + } + + if let fact = self.model.fact { + Text(fact) + } + } + .task { await self.model.onTask() } + } +} + +struct ContentPreviews: PreviewProvider { + static var previews: some View { + ContentView(model: CombineNumberFactModel()) + } +} diff --git a/0242-reliably-testing-async-pt5/ReliablyTestingAsync/ReliablyTestingAsync/Countdown.swift b/0242-reliably-testing-async-pt5/ReliablyTestingAsync/ReliablyTestingAsync/Countdown.swift new file mode 100644 index 00000000..2ca64e67 --- /dev/null +++ b/0242-reliably-testing-async-pt5/ReliablyTestingAsync/ReliablyTestingAsync/Countdown.swift @@ -0,0 +1,123 @@ +import Clocks +import SwiftUI + +struct CountdownDemo: View { + @State var countdown = 1000 + @State var isConfettiVisible = false + let clock: any Clock + + init(clock: some Clock = ContinuousClock()) { + self.clock = clock + } + + var body: some View { + ZStack { + Text("\(self.countdown)") + .font(.system(size: 200).bold()) + if self.isConfettiVisible { + ForEach(1...100, id: \.self) { _ in + ConfettiView() + .offset(x: .random(in: -20...20), y: .random(in: -20...20)) + } + } + } + .task { + while true { + if self.countdown == 0 { + self.isConfettiVisible = true + break + } + try? await self.clock.sleep(for: .seconds(1)) + self.countdown -= 1 + } + } + } +} + +struct ParticlesModifier: ViewModifier { + @State var duration = Double.random(in: 2...5) + @State var time = 0.0 + @State var scale = 0.3 + + func body(content: Content) -> some View { + content + .scaleEffect(self.scale) + .modifier(ParticlesEffect(time: self.time)) + .opacity(1 - self.time / self.duration) + .onAppear { + withAnimation(.easeOut(duration: self.duration)) { + self.self.time = self.duration + self.self.scale = 2.0 + } + } + } +} + +struct ParticlesEffect: GeometryEffect { + var direction = Double.random(in: -Double.pi...Double.pi) + var distance = Double.random(in: 20...400) + var time: Double + + var animatableData: Double { + get { self.time } + set { self.time = newValue } + } + func effectValue(size: CGSize) -> ProjectionTransform { + ProjectionTransform( + CGAffineTransform( + translationX: self.distance * cos(self.direction) * self.time, + y: self.distance * sin(self.direction) * self.time + ) + ) + } +} + +struct ConfettiView: View { + @State var anchor = CGFloat.random(in: 0...1).rounded() + @State var color = pointFreeColors.randomElement()! + @State var isAnimating = false + @State var rotationOffsetX = Double.random(in: 0...360) + @State var rotationOffsetY = Double.random(in: 0...360) + @State var speedX = Double.random(in: 0.5...2) + @State var speedZ = Double.random(in: 0.5...2) + + var body: some View { + Rectangle() + .fill(self.color) + .frame(width: 20, height: 20, alignment: .center) + .onAppear(perform: { isAnimating = true }) + .rotation3DEffect( + .degrees(rotationOffsetX + (isAnimating ? 360 : 0)), axis: (x: 1, y: 0, z: 0) + ) + .animation( + Animation.linear(duration: self.speedX).repeatForever(autoreverses: false), + value: isAnimating + ) + .rotation3DEffect( + .degrees(rotationOffsetY + (isAnimating ? 360 : 0)), axis: (x: 0, y: 0, z: 1), + anchor: UnitPoint(x: self.anchor, y: self.anchor) + ) + .animation( + Animation.linear(duration: self.speedZ).repeatForever(autoreverses: false), + value: isAnimating + ) + .modifier(ParticlesModifier()) + } +} + +private let pointFreeColors = [ + Color.init(red: 152/255, green: 239/255, blue: 181/255), + Color.init(red: 252/255, green: 241/255, blue: 143/255), + Color.init(red: 113/255, green: 201/255, blue: 250/255), + Color.init(red: 141/255, green: 81/255, blue: 246/255), +] + +struct CountdownDemo_Previews: PreviewProvider { + static var previews: some View { + let _ = swift_task_enqueueGlobal_hook = { job, _ in + MainActor.shared.enqueue(job) + } + + CountdownDemo(clock: .immediate) + } +} diff --git a/0242-reliably-testing-async-pt5/ReliablyTestingAsync/ReliablyTestingAsync/Info.plist b/0242-reliably-testing-async-pt5/ReliablyTestingAsync/ReliablyTestingAsync/Info.plist new file mode 100644 index 00000000..6a6654d9 --- /dev/null +++ b/0242-reliably-testing-async-pt5/ReliablyTestingAsync/ReliablyTestingAsync/Info.plist @@ -0,0 +1,11 @@ + + + + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + + diff --git a/0242-reliably-testing-async-pt5/ReliablyTestingAsync/ReliablyTestingAsync/Preview Content/Preview Assets.xcassets/Contents.json b/0242-reliably-testing-async-pt5/ReliablyTestingAsync/ReliablyTestingAsync/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/0242-reliably-testing-async-pt5/ReliablyTestingAsync/ReliablyTestingAsync/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/0242-reliably-testing-async-pt5/ReliablyTestingAsync/ReliablyTestingAsyncTests/LockIsolated.swift b/0242-reliably-testing-async-pt5/ReliablyTestingAsync/ReliablyTestingAsyncTests/LockIsolated.swift new file mode 100644 index 00000000..2e368512 --- /dev/null +++ b/0242-reliably-testing-async-pt5/ReliablyTestingAsync/ReliablyTestingAsyncTests/LockIsolated.swift @@ -0,0 +1,26 @@ +import Foundation + +public final class LockIsolated: @unchecked Sendable { + private var _value: Value + private let lock = NSRecursiveLock() + public init(_ value: @autoclosure @Sendable () throws -> Value) rethrows { + self._value = try value() + } + public func withValue( + _ operation: (inout Value) throws -> T + ) rethrows -> T { + try self.lock.withLock { + var value = self._value + defer { self._value = value } + return try operation(&value) + } + } +} + +extension LockIsolated where Value: Sendable { + public var value: Value { + self.lock.withLock { + self._value + } + } +} diff --git a/0242-reliably-testing-async-pt5/ReliablyTestingAsync/ReliablyTestingAsyncTests/NumberFactModelTests.swift b/0242-reliably-testing-async-pt5/ReliablyTestingAsync/ReliablyTestingAsyncTests/NumberFactModelTests.swift new file mode 100644 index 00000000..14499fc0 --- /dev/null +++ b/0242-reliably-testing-async-pt5/ReliablyTestingAsync/ReliablyTestingAsyncTests/NumberFactModelTests.swift @@ -0,0 +1,371 @@ +import Combine +import Dependencies +import XCTest +@testable import ReliablyTestingAsync + +@MainActor +final class NumberFactModelTests: XCTestCase { + func testIncrementDecrement() { + let model = NumberFactModel() + model.incrementButtonTapped() + XCTAssertEqual(model.count, 1) + model.decrementButtonTapped() + XCTAssertEqual(model.count, 0) + } + + func testIncrementDecrement_Combine() { + let model = CombineNumberFactModel() + model.incrementButtonTapped() + XCTAssertEqual(model.count, 1) + model.decrementButtonTapped() + XCTAssertEqual(model.count, 0) + } + + func testGetFact() async { + let model = withDependencies { + $0.numberFact.fact = { "\($0) is a good number." } + } operation: { + NumberFactModel() + } + await model.getFactButtonTapped() + XCTAssertEqual(model.fact, "0 is a good number.") + + model.incrementButtonTapped() + XCTAssertEqual(model.fact, nil) + + await model.getFactButtonTapped() + XCTAssertEqual(model.fact, "1 is a good number.") + } + + func testGetFact_Combine() { + let model = withDependencies { + $0.mainQueue = .immediate + $0.numberFact.factPublisher = { + Just("\($0) is a good number.") + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + } operation: { + CombineNumberFactModel() + } + model.getFactButtonTapped() + XCTAssertEqual(model.fact, "0 is a good number.") + + model.incrementButtonTapped() + XCTAssertEqual(model.fact, nil) + + model.getFactButtonTapped() + XCTAssertEqual(model.fact, "1 is a good number.") + } + + func testFactClearsOut() async { + let fact = AsyncStream.makeStream(of: String.self) + + let model = withDependencies { + $0.numberFact.fact = { _ in + await fact.stream.first(where: { _ in true })! + } + } operation: { + NumberFactModel() + } + model.fact = "An old fact about 0." + + let task = Task { await model.getFactButtonTapped() } + await Task.yield() + XCTAssertEqual(model.fact, nil) + fact.continuation.yield("0 is a good number.") + await task.value + XCTAssertEqual(model.fact, "0 is a good number.") + } + + func testFactClearsOut_Combine() { + let fact = PassthroughSubject() + + let model = withDependencies { + $0.mainQueue = .immediate + $0.numberFact.factPublisher = { _ in fact.eraseToAnyPublisher() } + } operation: { + CombineNumberFactModel() + } + model.fact = "An old fact about 0." + + model.getFactButtonTapped() + XCTAssertEqual(model.fact, nil) + fact.send("0 is a good number.") + XCTAssertEqual(model.fact, "0 is a good number.") + } + + func testFactClearsOut_MainSerialExecutor() async { + swift_task_enqueueGlobal_hook = { job, _ in + MainActor.shared.enqueue(job) + } + + let model = withDependencies { + $0.numberFact.fact = { "\($0) is a good number." } + } operation: { + NumberFactModel() + } + model.fact = "An old fact about 0." + + let task = Task { await model.getFactButtonTapped() } + await Task.yield() + XCTAssertEqual(model.fact, nil) + await task.value + XCTAssertEqual(model.fact, "0 is a good number.") + } + + func testFactIsLoading() async { + let fact = AsyncStream.makeStream(of: String.self) + + let model = withDependencies { + $0.numberFact.fact = { _ in + await fact.stream.first(where: { _ in true })! + } + } operation: { + NumberFactModel() + } + model.fact = "An old fact about 0." + + let task = Task { await model.getFactButtonTapped() } + await Task.yield() + XCTAssertEqual(model.isLoading, true) + fact.continuation.yield("0 is a good number.") + await task.value + XCTAssertEqual(model.fact, "0 is a good number.") + XCTAssertEqual(model.isLoading, false) + } + + func testFactIsLoading_Combine() async { + let fact = PassthroughSubject() + + let model = withDependencies { + $0.mainQueue = .immediate + $0.numberFact.factPublisher = { _ in fact.eraseToAnyPublisher() } + } operation: { + CombineNumberFactModel() + } + model.fact = "An old fact about 0." + + model.getFactButtonTapped() + XCTAssertEqual(model.isLoading, true) + fact.send("0 is a good number.") + XCTAssertEqual(model.fact, "0 is a good number.") + XCTAssertEqual(model.isLoading, false) + } + + func testFactIsLoading_MainSerialExecutor() async { + swift_task_enqueueGlobal_hook = { job, _ in + MainActor.shared.enqueue(job) + } + + let model = withDependencies { + $0.numberFact.fact = { "\($0) is a good number." } + } operation: { + NumberFactModel() + } + model.fact = "An old fact about 0." + + let task = Task { await model.getFactButtonTapped() } + await Task.yield() + XCTAssertEqual(model.isLoading, true) + await task.value + XCTAssertEqual(model.fact, "0 is a good number.") + XCTAssertEqual(model.isLoading, false) + } + + func testBackToBackGetFact() async throws { + let fact0 = AsyncStream.makeStream(of: String.self) + let fact1 = AsyncStream.makeStream(of: String.self) + let callCount = LockIsolated(0) + + let model = withDependencies { + $0.numberFact.fact = { number in + callCount.withValue { $0 += 1 } + if callCount.value == 1 { + return await fact0.stream.first(where: { _ in true }) ?? "" + } else if callCount.value == 2 { + return await fact1.stream.first(where: { _ in true }) ?? "" + } else { + fatalError() + } + } + } operation: { + NumberFactModel() + } + + let task0 = Task { await model.getFactButtonTapped() } + let task1 = Task { await model.getFactButtonTapped() } + await Task.yield() + fact1.continuation.yield("0 is a great number.") + try await Task.sleep(for: .milliseconds(100)) + fact0.continuation.yield("0 is a better number.") + await task0.value + await task1.value + XCTAssertEqual(model.fact, "0 is a great number.") + } + + func testBackToBackGetFact_Combine() { + let fact0 = PassthroughSubject() + let fact1 = PassthroughSubject() + let callCount = LockIsolated(0) + + let model = withDependencies { + $0.mainQueue = .immediate + $0.numberFact.factPublisher = { number in + callCount.withValue { $0 += 1 } + if callCount.value == 1 { + return fact0.eraseToAnyPublisher() + } else if callCount.value == 2 { + return fact1.eraseToAnyPublisher() + } else { + fatalError() + } + } + } operation: { + CombineNumberFactModel() + } + + model.getFactButtonTapped() + model.getFactButtonTapped() + fact1.send("0 is a great number.") + fact0.send("0 is a better number.") + XCTAssertEqual(model.fact, "0 is a great number.") + } + func testBackToBackGetFact_MainSerialExecutor() async throws { + swift_task_enqueueGlobal_hook = { job, _ in + MainActor.shared.enqueue(job) + } + + let callCount = LockIsolated(0) + + let model = withDependencies { + $0.numberFact.fact = { number in + callCount.withValue { $0 += 1 } + if callCount.value == 1 { + return "0 is a better number." + } else if callCount.value == 2 { + return "0 is a great number." + } else { + fatalError() + } + } + } operation: { + NumberFactModel() + } + + let task0 = Task { await model.getFactButtonTapped() } + let task1 = Task { await model.getFactButtonTapped() } + await Task.yield() + await task0.value + await task1.value + XCTAssertEqual(model.fact, "0 is a great number.") + } + + + func testCancel() async { + let model = withDependencies { + $0.numberFact.fact = { _ in try await Task.never() } + } operation: { + NumberFactModel() + } + let task = Task { await model.getFactButtonTapped() } + await Task.megaYield() + model.cancelButtonTapped() + await task.value + XCTAssertEqual(model.fact, nil) + } + + func testCancel_Combine() { + let model = withDependencies { + $0.numberFact.factPublisher = { _ in Empty(completeImmediately: false).eraseToAnyPublisher() } + } operation: { + CombineNumberFactModel() + } + model.getFactButtonTapped() + model.cancelButtonTapped() + XCTAssertEqual(model.fact, nil) + XCTAssertNil(model.factCancellable) + XCTAssertEqual(model.isLoading, false) + } + + func testCancel_MainSerialExecutor() async { + swift_task_enqueueGlobal_hook = { job, _ in + MainActor.shared.enqueue(job) + } + + let model = withDependencies { + $0.numberFact.fact = { + await Task.yield() + try Task.checkCancellation() + return "\($0) is a good number." + } + } operation: { + NumberFactModel() + } + let task = Task { await model.getFactButtonTapped() } + await Task.yield() + model.cancelButtonTapped() + await task.value + XCTAssertEqual(model.fact, nil) + } + + + func testScreenshots() async { + let model = NumberFactModel() + + let task = Task { await model.onTask() } + + await Task.megaYield() + NotificationCenter.default.post(name: UIApplication.userDidTakeScreenshotNotification, object: nil) + while model.count != 1 { + await Task.yield() + } + XCTAssertEqual(model.count, 1) + + NotificationCenter.default.post(name: UIApplication.userDidTakeScreenshotNotification, object: nil) + while model.count != 2 { + await Task.yield() + } + XCTAssertEqual(model.count, 2) + } + + func testScreenshots_Combine() { + let model = CombineNumberFactModel() + + model.onTask() + + NotificationCenter.default.post(name: UIApplication.userDidTakeScreenshotNotification, object: nil) + XCTAssertEqual(model.count, 1) + + NotificationCenter.default.post(name: UIApplication.userDidTakeScreenshotNotification, object: nil) + XCTAssertEqual(model.count, 2) + } + + func testScreenshots_MainSerialExecutor() async { + swift_task_enqueueGlobal_hook = { job, _ in + MainActor.shared.enqueue(job) + } + + let model = NumberFactModel() + + let task = Task { await model.onTask() } + await Task.yield() + NotificationCenter.default.post(name: UIApplication.userDidTakeScreenshotNotification, object: nil) + await Task.yield() + XCTAssertEqual(model.count, 1) + + NotificationCenter.default.post(name: UIApplication.userDidTakeScreenshotNotification, object: nil) + await Task.yield() + XCTAssertEqual(model.count, 2) + } +} + +extension Task where Success == Never, Failure == Never { + static func megaYield() async { + for _ in 1...20 { + await Task.detached(priority: .background) { + await Task.yield() + }.value + } + } +} diff --git a/0242-reliably-testing-async-pt5/ReliablyTestingAsync/ReliablyTestingAsyncTests/ReliablyTestingAsync.xctestplan b/0242-reliably-testing-async-pt5/ReliablyTestingAsync/ReliablyTestingAsyncTests/ReliablyTestingAsync.xctestplan new file mode 100644 index 00000000..45b5984b --- /dev/null +++ b/0242-reliably-testing-async-pt5/ReliablyTestingAsync/ReliablyTestingAsyncTests/ReliablyTestingAsync.xctestplan @@ -0,0 +1,24 @@ +{ + "configurations" : [ + { + "id" : "1DBD9DAB-B1B3-419B-9A10-F5EDE94A2585", + "name" : "Configuration 1", + "options" : { + + } + } + ], + "defaultOptions" : { + + }, + "testTargets" : [ + { + "target" : { + "containerPath" : "container:ReliablyTestingAsync.xcodeproj", + "identifier" : "CAC2DD8C2A30B829007675BD", + "name" : "ReliablyTestingAsyncTests" + } + } + ], + "version" : 1 +} diff --git a/0242-reliably-testing-async-pt5/ReliablyTestingAsync/ReliablyTestingAsyncTests/ReliablyTestingAsyncTests.swift b/0242-reliably-testing-async-pt5/ReliablyTestingAsync/ReliablyTestingAsyncTests/ReliablyTestingAsyncTests.swift new file mode 100644 index 00000000..340e82b4 --- /dev/null +++ b/0242-reliably-testing-async-pt5/ReliablyTestingAsync/ReliablyTestingAsyncTests/ReliablyTestingAsyncTests.swift @@ -0,0 +1,202 @@ +import XCTest +@testable import ReliablyTestingAsync + +final class ReliablyTestingAsyncTests: XCTestCase { + func testBasics() async throws { + let start = Date() + try await Task.sleep(for: .seconds(1)) + let end = Date() + XCTAssertEqual(end.timeIntervalSince(start), 1, accuracy: 0.1) + } + + //@MainActor + func testTaskStart() async { + let values = LockIsolated([Int]()) + let task = Task { + values.withValue { $0.append(1) } + print(#line, { Thread.current }()) + } + values.withValue { $0.append(2) } + print(#line, { Thread.current }()) + await task.value + XCTAssertEqual(values.value, [2, 1]) + } + + func testTaskStart_MainSerialExecutor() async { + swift_task_enqueueGlobal_hook = { job, _ in + MainActor.shared.enqueue(job) + } + + let values = LockIsolated([Int]()) + let task = Task { + values.withValue { $0.append(1) } + } + values.withValue { $0.append(2) } + await task.value + XCTAssertEqual(values.value, [2, 1]) + } + + func testTaskStartOrder() async { + let values = LockIsolated([Int]()) + let task1 = Task { + values.withValue { $0.append(1) } + print({ Thread.current }()) + } + let task2 = Task { + values.withValue { $0.append(2) } + print({ Thread.current }()) + } + _ = await (task1.value, task2.value) + XCTAssertEqual(values.value, [1, 2]) + } + + func testTaskStartOrder_MainSerialExecutor() async { + swift_task_enqueueGlobal_hook = { job, _ in + MainActor.shared.enqueue(job) + } + + let values = LockIsolated([Int]()) + let task1 = Task { + values.withValue { $0.append(1) } + } + let task2 = Task { + values.withValue { $0.append(2) } + } + _ = await (task1.value, task2.value) + XCTAssertEqual(values.value, [1, 2]) + } + + func testTaskGroupStartOrder() async { + let values = await withTaskGroup(of: [Int].self) { group in + for index in 1...100 { + group.addTask { [index] } + } + return await group.reduce(into: []) { $0 += $1 } + } + XCTAssertEqual(values, Array(1...100)) + } + + func testTaskGroupStartOrder_MainSerialExecutor() async { + swift_task_enqueueGlobal_hook = { job, _ in + MainActor.shared.enqueue(job) + } + + let values = await withTaskGroup(of: [Int].self) { group in + for index in 1...100 { + group.addTask { [index] } + } + return await group.reduce(into: []) { $0 += $1 } + } + XCTAssertEqual(values, Array(1...100)) + } + + func testYieldScheduling() async { + let count = 10 + let values = LockIsolated<[Int]>([]) + let tasks = (0...count).map { n in + Task { + values.withValue { $0.append(n * 2) } + await Task.yield() + values.withValue { $0.append(n * 2 + 1) } + } + } + for task in tasks { await task.value } + + XCTAssertEqual( + values.value, + Array(0...count).map { $0 * 2 } // evens less than or equal to max + + Array(0...count).map { $0 * 2 + 1 } // odds less than or equal to max + ) + } + + func testYieldScheduling_MainSerialExecutor() async { + swift_task_enqueueGlobal_hook = { job, _ in + MainActor.shared.enqueue(job) + } + + let count = 10 + let values = LockIsolated<[Int]>([]) + let tasks = (0...count).map { n in + Task { + values.withValue { $0.append(n * 2) } + await Task.yield() + values.withValue { $0.append(n * 2 + 1) } + } + } + for task in tasks { await task.value } + + XCTAssertEqual( + values.value, + Array(0...count).map { $0 * 2 } // evens less than or equal to max + + Array(0...count).map { $0 * 2 + 1 } // odds less than or equal to max + ) + } + /* + + [task0, task1, task2] + + [{task0}, task1, task2] + [0] + + [{task1}, task2, task0] + [0, 2] + + [{task2}, task0, task1] + [0, 2, 4] + + [{task0}, task1, task2] + [0, 2, 4, 1] + + [{task1}, task2] + [0, 2, 4, 1, 3] + + [{task2}] + [0, 2, 4, 1, 3, 5] + + [] + [0, 2, 4, 1, 3, 5] + */ + + @MainActor + func testEnqueueHook() async throws { + swift_task_enqueueGlobal_hook = { job, _ in + MainActor.shared.enqueue(job) + } + let (bytes, _) = try await URLSession.shared.bytes(from: URL(string: "https://www.google.com")!) + for try await _ in bytes {} + } + + func testSomething() async { +// swift_task_enqueueGlobal_hook = { job, _ in +// MainActor.shared.enqueue(job) +// } + let toggle = Toggle() + await withTaskGroup(of: Void.self) { group in + for _ in 1...1000 { + group.addTask { + toggle.isOn.toggle() + } + } + } + XCTAssertEqual(toggle.isOn, false) + } +} + +class Toggle: @unchecked Sendable { + @ThreadSafe var isOn = false +} + +@propertyWrapper +struct ThreadSafe: Sendable { + private let lock = NSRecursiveLock() + private var _wrappedValue: Value + + var wrappedValue: Value { + get { self.lock.withLock { self._wrappedValue } } + set { self.lock.withLock { self._wrappedValue = newValue } } + } + + init(wrappedValue: Value) { + self._wrappedValue = wrappedValue + } +} diff --git a/README.md b/README.md index 2a0a2a9f..a9f117ba 100644 --- a/README.md +++ b/README.md @@ -243,3 +243,4 @@ This repository is the home of code written on episodes of [Point-Free](https:// 1. [Reliable Async Tests: More Problems](0239-reliably-testing-async-pt2) 1. [Reliable Async Tests: 😳](0240-reliably-testing-async-pt3) 1. [Reliable Async Tests: 🥹](0241-reliably-testing-async-pt4) +1. [Reliable Async Tests: The Point](0242-reliably-testing-async-pt5)