diff --git a/README.md b/README.md index e0959aa..02d76b7 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ of navigation (_e.g._, sheets, drill-downs, alerts), many side effects (timers, data persistence), and do so in a way that is testable and modular. This application was built over the course of [many episodes][modern-swiftui-collection] on -Point-Free, a video series exploring functional programming and the Swift language, hosted by +Point-Free, a video series exploring advanced programming topics in the Swift language, hosted by [Brandon Williams](https://twitter.com/mbrandonw) and [Stephen Celis](https://twitter.com/stephencelis). @@ -66,25 +66,29 @@ Our SyncUps application is a rebuild of Apple's Scrumdinger application, but wit modern, best practices for SwiftUI development. We faithfully recreate the Scrumdinger, but with some key additions: - 1. Identifiers are made type safe using our [Tagged library][tagged-gh]. This prevents us from - writing non-sensical code, such as comparing a `SyncUp.ID` to a `Attendee.ID`. - 2. Instead of using bare arrays in feature logic we use an "identified" array from our - [IdentifiedCollections][identified-collections-gh] library. This allows you to read and modify - elements of the collection via their ID rather than positional index, which can be error prone - and lead to bugs or crashes. - 3. _All_ navigation is driven off of state, including sheets, drill-downs and alerts. This makes + 1. _All_ navigation is driven off of state, including sheets, drill-downs and alerts. This makes it possible to deep link into any screen of the app by just constructing a piece of state and handing it off to SwiftUI. - 4. Further, each view represents its navigation destinations as a single enum, which gives us - compile time proof that two destinations cannot be active at the same time. This cannot be - accomplished with default SwiftUI tools, but can be done with our [SwiftUINavigation - library][swiftui-nav-gh]. - 5. All side effects are controlled. This includes access to the file system for persistence, access + 1. Further, when a feature can navigate to multiple destinations, an enum is used to model the + destinations, which gives us compile time proof that two destinations cannot be active at the + same time. This cannot be accomplished with default SwiftUI tools, but can be done with our + [SwiftNavigation library][swift-nav-gh]. + 1. Persistence is handled by our [Sharing][sharing-gh] library, which allows one to hold onto + shared state in an observable model or view, and under the hood any changes to the state will be + persisted to external storage, such as the file system. Even the global navigation is persisted + using the Sharing library. + 1. All side effects are controlled. This includes access to the file system for persistence, access to time-based asynchrony for timers, access to speech recognition APIs, and even the creation of dates and UUIDs. This allows us to run our application in specific execution contexts, which is very useful in tests and Xcode previews. We accomplish this using our [Dependencies][dependencies-gh] library. - 6. The project includes a full test suite. Since all of navigation is driven off of state, and + 1. Identifiers are made type safe using our [Tagged library][tagged-gh]. This prevents us from + writing non-sensical code, such as comparing a `SyncUp.ID` to a `Attendee.ID`. + 1. Instead of using bare arrays in feature logic we use an "identified" array from our + [IdentifiedCollections][identified-collections-gh] library. This allows you to read and modify + elements of the collection via their ID rather than positional index, which can be error prone + and lead to bugs or crashes. + 1. The project includes a full test suite. Since all of navigation is driven off of state, and because we controlled all dependencies, we can write very comprehensive and nuanced tests. For example, we can write a unit test that proves that when a sync-up meeting's timer runs out the screen pops off the stack and a new transcript is added to the sync-up. Such a test would be @@ -103,5 +107,6 @@ Here is a list of ports of the app: [scrumdinger-dl]: https://docs-assets.developer.apple.com/published/1ea2eec121b90031e354288912a76357/TranscribingSpeechToText.zip [tagged-gh]: http://github.com/pointfreeco/swift-tagged [identified-collections-gh]: http://github.com/pointfreeco/swift-identified-collections -[swiftui-nav-gh]: http://github.com/pointfreeco/swiftui-navigation +[swift-nav-gh]: http://github.com/pointfreeco/swift-navigation [dependencies-gh]: http://github.com/pointfreeco/swift-dependencies +[sharing-gh]: https://github.com/pointfreeco/swift-sharing diff --git a/SyncUps.xcworkspace/xcshareddata/swiftpm/Package.resolved b/SyncUps.xcworkspace/xcshareddata/swiftpm/Package.resolved index 0d8ebc4..f57f873 100644 --- a/SyncUps.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/SyncUps.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "4330f83645f40a08674938f5dee887a1bbc67b200cdcfdc80b3b2c258d0336b8", + "originHash" : "a41d8ea0a7e275eaf005ddc4bfd786d637276802746d02247b43cc5cfb463378", "pins" : [ { "identity" : "combine-schedulers", @@ -40,16 +40,16 @@ { "identity" : "swift-concurrency-extras", "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-concurrency-extras.git", + "location" : "https://github.com/pointfreeco/swift-concurrency-extras", "state" : { - "revision" : "6054df64b55186f08b6d0fd87152081b8ad8d613", - "version" : "1.2.0" + "revision" : "163409ef7dae9d960b87f34b51587b6609a76c1f", + "version" : "1.3.0" } }, { "identity" : "swift-custom-dump", "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-custom-dump.git", + "location" : "https://github.com/pointfreeco/swift-custom-dump", "state" : { "revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1", "version" : "1.3.3" @@ -58,16 +58,16 @@ { "identity" : "swift-dependencies", "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-dependencies.git", + "location" : "https://github.com/pointfreeco/swift-dependencies", "state" : { - "revision" : "0fc0255e780bf742abeef29dec80924f5f0ae7b9", - "version" : "1.4.1" + "revision" : "7d2eb4ad20efb2838269645410d26b64ca48d8aa", + "version" : "1.6.1" } }, { "identity" : "swift-identified-collections", "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-identified-collections.git", + "location" : "https://github.com/pointfreeco/swift-identified-collections", "state" : { "revision" : "2f5ab6e091dd032b63dacbda052405756010dc3b", "version" : "1.1.0" @@ -87,8 +87,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-perception", "state" : { - "revision" : "bc67aa8e461351c97282c2419153757a446ae1c9", - "version" : "1.3.5" + "revision" : "8d52279b9809ef27eabe7d5420f03734528f19da", + "version" : "1.4.1" + } + }, + { + "identity" : "swift-sharing", + "kind" : "remoteSourceControl", + "location" : "http://github.com/pointfreeco/swift-sharing/", + "state" : { + "revision" : "0afc5170fc2d2a5f6d2ef1dd284cf4f698740603", + "version" : "1.0.1" } }, { @@ -96,14 +105,14 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-syntax", "state" : { - "revision" : "2bc86522d115234d1f588efe2bcb4ce4be8f8b82", - "version" : "510.0.3" + "revision" : "0687f71944021d616d34d922343dcef086855920", + "version" : "600.0.1" } }, { "identity" : "swift-tagged", "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-tagged.git", + "location" : "https://github.com/pointfreeco/swift-tagged", "state" : { "revision" : "3907a9438f5b57d317001dc99f3f11b46882272b", "version" : "0.10.0" @@ -112,10 +121,10 @@ { "identity" : "xctest-dynamic-overlay", "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay.git", + "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", "state" : { - "revision" : "770f990d3e4eececb57ac04a6076e22f8c97daeb", - "version" : "1.4.2" + "revision" : "a3f634d1a409c7979cabc0a71b3f26ffa9fc8af1", + "version" : "1.4.3" } } ], diff --git a/SyncUps/SyncUps.xcodeproj/project.pbxproj b/SyncUps/SyncUps.xcodeproj/project.pbxproj index 5b37574..94b464c 100644 --- a/SyncUps/SyncUps.xcodeproj/project.pbxproj +++ b/SyncUps/SyncUps.xcodeproj/project.pbxproj @@ -9,12 +9,10 @@ /* Begin PBXBuildFile section */ 2A368893298ADD2500E8C33A /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A368892298ADD2500E8C33A /* App.swift */; }; CA1C303F2C358FEB001EE466 /* CustomDump in Frameworks */ = {isa = PBXBuildFile; productRef = CA1C303E2C358FEB001EE466 /* CustomDump */; }; - CA1D22EA2991BC7000B529DE /* AppTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA1D22E92991BC7000B529DE /* AppTests.swift */; }; CA350C752984506E00434F8A /* SyncUpsApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA350C742984506E00434F8A /* SyncUpsApp.swift */; }; CA350CAF298450BF00434F8A /* SyncUpDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA350CA1298450BF00434F8A /* SyncUpDetail.swift */; }; CA350CB0298450BF00434F8A /* RecordMeeting.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA350CA2298450BF00434F8A /* RecordMeeting.swift */; }; CA350CB1298450BF00434F8A /* SyncUpForm.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA350CA3298450BF00434F8A /* SyncUpForm.swift */; }; - CA350CB2298450BF00434F8A /* DataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA350CA5298450BF00434F8A /* DataManager.swift */; }; CA350CB3298450BF00434F8A /* SpeechClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA350CA6298450BF00434F8A /* SpeechClient.swift */; }; CA350CB4298450BF00434F8A /* OpenSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA350CA7298450BF00434F8A /* OpenSettings.swift */; }; CA350CB5298450BF00434F8A /* SoundEffectClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA350CA8298450BF00434F8A /* SoundEffectClient.swift */; }; @@ -28,13 +26,17 @@ CA350CCD2984518200434F8A /* SyncUpDetailTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA350CC92984518200434F8A /* SyncUpDetailTests.swift */; }; CA350CCF2984518A00434F8A /* SyncUpsListUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA350CCE2984518A00434F8A /* SyncUpsListUITests.swift */; }; CA350CD2298452F300434F8A /* ding.wav in Resources */ = {isa = PBXBuildFile; fileRef = CA350CD1298452F300434F8A /* ding.wav */; }; + CA763F4E2CEF88FC00C1CE55 /* AppPath.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA763F4D2CEF88FC00C1CE55 /* AppPath.swift */; }; + CA763F5C2CEF9B6700C1CE55 /* DependenciesTestSupport in Frameworks */ = {isa = PBXBuildFile; productRef = CA763F5B2CEF9B6700C1CE55 /* DependenciesTestSupport */; }; CA8586B82C61210E00A53451 /* SwiftUINavigation in Frameworks */ = {isa = PBXBuildFile; productRef = CA8586B72C61210E00A53451 /* SwiftUINavigation */; }; + CA99A59C2CD9172000E6F7B0 /* Sharing in Frameworks */ = {isa = PBXBuildFile; productRef = CA99A59B2CD9172000E6F7B0 /* Sharing */; }; CAAF4DC92C3586AD00774888 /* IssueReporting in Frameworks */ = {isa = PBXBuildFile; productRef = CAAF4DC82C3586AD00774888 /* IssueReporting */; }; CAAF4DCC2C3586C900774888 /* IdentifiedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = CAAF4DCB2C3586C900774888 /* IdentifiedCollections */; }; CAAF4DCF2C3586E200774888 /* Tagged in Frameworks */ = {isa = PBXBuildFile; productRef = CAAF4DCE2C3586E200774888 /* Tagged */; }; CAAF4DD42C35899500774888 /* Dependencies in Frameworks */ = {isa = PBXBuildFile; productRef = CAAF4DD32C35899500774888 /* Dependencies */; }; CAAF4DD72C3589A700774888 /* ConcurrencyExtras in Frameworks */ = {isa = PBXBuildFile; productRef = CAAF4DD62C3589A700774888 /* ConcurrencyExtras */; }; CAAF4DD92C3589D400774888 /* DependenciesMacros in Frameworks */ = {isa = PBXBuildFile; productRef = CAAF4DD82C3589D400774888 /* DependenciesMacros */; }; + CAF8AAB12CD91A95007192A6 /* Sharing in Frameworks */ = {isa = PBXBuildFile; productRef = CAF8AAB02CD91A95007192A6 /* Sharing */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -56,7 +58,6 @@ /* Begin PBXFileReference section */ 2A368892298ADD2500E8C33A /* App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = ""; }; - CA1D22E92991BC7000B529DE /* AppTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTests.swift; sourceTree = ""; }; CA350C712984506E00434F8A /* SyncUps.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SyncUps.app; sourceTree = BUILT_PRODUCTS_DIR; }; CA350C742984506E00434F8A /* SyncUpsApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncUpsApp.swift; sourceTree = ""; }; CA350C812984506F00434F8A /* SyncUpsTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SyncUpsTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -64,7 +65,6 @@ CA350CA1298450BF00434F8A /* SyncUpDetail.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SyncUpDetail.swift; sourceTree = ""; }; CA350CA2298450BF00434F8A /* RecordMeeting.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RecordMeeting.swift; sourceTree = ""; }; CA350CA3298450BF00434F8A /* SyncUpForm.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SyncUpForm.swift; sourceTree = ""; }; - CA350CA5298450BF00434F8A /* DataManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DataManager.swift; sourceTree = ""; }; CA350CA6298450BF00434F8A /* SpeechClient.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SpeechClient.swift; sourceTree = ""; }; CA350CA7298450BF00434F8A /* OpenSettings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OpenSettings.swift; sourceTree = ""; }; CA350CA8298450BF00434F8A /* SoundEffectClient.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SoundEffectClient.swift; sourceTree = ""; }; @@ -78,6 +78,7 @@ CA350CC92984518200434F8A /* SyncUpDetailTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SyncUpDetailTests.swift; sourceTree = ""; }; CA350CCE2984518A00434F8A /* SyncUpsListUITests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SyncUpsListUITests.swift; sourceTree = ""; }; CA350CD1298452F300434F8A /* ding.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = ding.wav; sourceTree = ""; }; + CA763F4D2CEF88FC00C1CE55 /* AppPath.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPath.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -85,8 +86,10 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + CA99A59C2CD9172000E6F7B0 /* Sharing in Frameworks */, CAAF4DD72C3589A700774888 /* ConcurrencyExtras in Frameworks */, CAAF4DCF2C3586E200774888 /* Tagged in Frameworks */, + CAF8AAB12CD91A95007192A6 /* Sharing in Frameworks */, CAAF4DD92C3589D400774888 /* DependenciesMacros in Frameworks */, CA1C303F2C358FEB001EE466 /* CustomDump in Frameworks */, CA8586B82C61210E00A53451 /* SwiftUINavigation in Frameworks */, @@ -100,6 +103,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + CA763F5C2CEF9B6700C1CE55 /* DependenciesTestSupport in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -138,6 +142,7 @@ isa = PBXGroup; children = ( 2A368892298ADD2500E8C33A /* App.swift */, + CA763F4D2CEF88FC00C1CE55 /* AppPath.swift */, CA350CAB298450BF00434F8A /* Helpers.swift */, CA350CAA298450BF00434F8A /* Models.swift */, CA350CA2298450BF00434F8A /* RecordMeeting.swift */, @@ -155,7 +160,6 @@ CA350C842984506F00434F8A /* SyncUpsTests */ = { isa = PBXGroup; children = ( - CA1D22E92991BC7000B529DE /* AppTests.swift */, CA350CC62984518200434F8A /* RecordMeetingTests.swift */, CA350CC92984518200434F8A /* SyncUpDetailTests.swift */, CA350CC82984518200434F8A /* SyncUpFormTests.swift */, @@ -175,7 +179,6 @@ CA350CA4298450BF00434F8A /* Dependencies */ = { isa = PBXGroup; children = ( - CA350CA5298450BF00434F8A /* DataManager.swift */, CA350CA6298450BF00434F8A /* SpeechClient.swift */, CA350CA7298450BF00434F8A /* OpenSettings.swift */, CA350CA8298450BF00434F8A /* SoundEffectClient.swift */, @@ -223,6 +226,8 @@ CAAF4DD82C3589D400774888 /* DependenciesMacros */, CA1C303E2C358FEB001EE466 /* CustomDump */, CA8586B72C61210E00A53451 /* SwiftUINavigation */, + CA99A59B2CD9172000E6F7B0 /* Sharing */, + CAF8AAB02CD91A95007192A6 /* Sharing */, ); productName = SyncUps; productReference = CA350C712984506E00434F8A /* SyncUps.app */; @@ -243,6 +248,7 @@ ); name = SyncUpsTests; packageProductDependencies = ( + CA763F5B2CEF9B6700C1CE55 /* DependenciesTestSupport */, ); productName = SyncUpsTests; productReference = CA350C812984506F00434F8A /* SyncUpsTests.xctest */; @@ -304,11 +310,11 @@ CAAF4DC72C3586AD00774888 /* XCRemoteSwiftPackageReference "xctest-dynamic-overlay" */, CAAF4DCA2C3586C900774888 /* XCRemoteSwiftPackageReference "swift-identified-collections" */, CAAF4DCD2C3586E200774888 /* XCRemoteSwiftPackageReference "swift-tagged" */, - CAAF4DD12C35874200774888 /* XCRemoteSwiftPackageReference "swift-syntax" */, CAAF4DD22C35899500774888 /* XCRemoteSwiftPackageReference "swift-dependencies" */, CAAF4DD52C3589A700774888 /* XCRemoteSwiftPackageReference "swift-concurrency-extras" */, CA1C303D2C358FEB001EE466 /* XCRemoteSwiftPackageReference "swift-custom-dump" */, CA8586B62C61210E00A53451 /* XCRemoteSwiftPackageReference "swift-navigation" */, + CAF8AAAF2CD91A95007192A6 /* XCRemoteSwiftPackageReference "swift-sharing" */, ); productRefGroup = CA350C722984506E00434F8A /* Products */; projectDirPath = ""; @@ -357,12 +363,12 @@ CA350C752984506E00434F8A /* SyncUpsApp.swift in Sources */, CA350CB3298450BF00434F8A /* SpeechClient.swift in Sources */, CA350CB5298450BF00434F8A /* SoundEffectClient.swift in Sources */, - CA350CB2298450BF00434F8A /* DataManager.swift in Sources */, CA350CB6298450BF00434F8A /* SyncUpsList.swift in Sources */, CA350CB8298450BF00434F8A /* Helpers.swift in Sources */, CA350CB1298450BF00434F8A /* SyncUpForm.swift in Sources */, 2A368893298ADD2500E8C33A /* App.swift in Sources */, CA350CB0298450BF00434F8A /* RecordMeeting.swift in Sources */, + CA763F4E2CEF88FC00C1CE55 /* AppPath.swift in Sources */, CA350CAF298450BF00434F8A /* SyncUpDetail.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -374,7 +380,6 @@ CA350CCD2984518200434F8A /* SyncUpDetailTests.swift in Sources */, CA350CCA2984518200434F8A /* RecordMeetingTests.swift in Sources */, CA350CCB2984518200434F8A /* SyncUpsListTests.swift in Sources */, - CA1D22EA2991BC7000B529DE /* AppTests.swift in Sources */, CA350CCC2984518200434F8A /* SyncUpFormTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -455,7 +460,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -512,7 +517,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; @@ -749,14 +754,6 @@ minimumVersion = 0.10.0; }; }; - CAAF4DD12C35874200774888 /* XCRemoteSwiftPackageReference "swift-syntax" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/swiftlang/swift-syntax"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 510.0.2; - }; - }; CAAF4DD22C35899500774888 /* XCRemoteSwiftPackageReference "swift-dependencies" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/pointfreeco/swift-dependencies"; @@ -773,6 +770,14 @@ minimumVersion = 1.1.0; }; }; + CAF8AAAF2CD91A95007192A6 /* XCRemoteSwiftPackageReference "swift-sharing" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "http://github.com/pointfreeco/swift-sharing"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.0.0; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -781,11 +786,20 @@ package = CA1C303D2C358FEB001EE466 /* XCRemoteSwiftPackageReference "swift-custom-dump" */; productName = CustomDump; }; + CA763F5B2CEF9B6700C1CE55 /* DependenciesTestSupport */ = { + isa = XCSwiftPackageProductDependency; + package = CAAF4DD22C35899500774888 /* XCRemoteSwiftPackageReference "swift-dependencies" */; + productName = DependenciesTestSupport; + }; CA8586B72C61210E00A53451 /* SwiftUINavigation */ = { isa = XCSwiftPackageProductDependency; package = CA8586B62C61210E00A53451 /* XCRemoteSwiftPackageReference "swift-navigation" */; productName = SwiftUINavigation; }; + CA99A59B2CD9172000E6F7B0 /* Sharing */ = { + isa = XCSwiftPackageProductDependency; + productName = Sharing; + }; CAAF4DC82C3586AD00774888 /* IssueReporting */ = { isa = XCSwiftPackageProductDependency; package = CAAF4DC72C3586AD00774888 /* XCRemoteSwiftPackageReference "xctest-dynamic-overlay" */; @@ -816,6 +830,11 @@ package = CAAF4DD22C35899500774888 /* XCRemoteSwiftPackageReference "swift-dependencies" */; productName = DependenciesMacros; }; + CAF8AAB02CD91A95007192A6 /* Sharing */ = { + isa = XCSwiftPackageProductDependency; + package = CAF8AAAF2CD91A95007192A6 /* XCRemoteSwiftPackageReference "swift-sharing" */; + productName = Sharing; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = CA350C692984506E00434F8A /* Project object */; diff --git a/SyncUps/SyncUps.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/SyncUps/SyncUps.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 7fc86c0..f57f873 100644 --- a/SyncUps/SyncUps.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/SyncUps/SyncUps.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "49bd7cf9c8f940c70a8cb0844438a7b6d778ba8149aa2ae4a2e8853fd33f7996", + "originHash" : "a41d8ea0a7e275eaf005ddc4bfd786d637276802746d02247b43cc5cfb463378", "pins" : [ { "identity" : "combine-schedulers", "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/combine-schedulers", "state" : { - "revision" : "9dc9cbe4bc45c65164fa653a563d8d8db61b09bb", - "version" : "1.0.0" + "revision" : "9fa31f4403da54855f1e2aeaeff478f4f0e40b13", + "version" : "1.0.2" } }, { @@ -24,8 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-clocks", "state" : { - "revision" : "a8421d68068d8f45fbceb418fbf22c5dad4afd33", - "version" : "1.0.2" + "revision" : "b9b24b69e2adda099a1fa381cda1eeec272d5b53", + "version" : "1.0.5" } }, { @@ -33,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections", "state" : { - "revision" : "94cf62b3ba8d4bed62680a282d4c25f9c63c2efb", - "version" : "1.1.0" + "revision" : "671108c96644956dddcd89dd59c203dcdb36cec7", + "version" : "1.1.4" } }, { @@ -42,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-concurrency-extras", "state" : { - "revision" : "6054df64b55186f08b6d0fd87152081b8ad8d613", - "version" : "1.2.0" + "revision" : "163409ef7dae9d960b87f34b51587b6609a76c1f", + "version" : "1.3.0" } }, { @@ -60,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-dependencies", "state" : { - "revision" : "350e1e119babe8525f9bd155b76640a5de270184", - "version" : "1.3.0" + "revision" : "7d2eb4ad20efb2838269645410d26b64ca48d8aa", + "version" : "1.6.1" } }, { @@ -87,8 +87,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-perception", "state" : { - "revision" : "bc67aa8e461351c97282c2419153757a446ae1c9", - "version" : "1.3.5" + "revision" : "8d52279b9809ef27eabe7d5420f03734528f19da", + "version" : "1.4.1" + } + }, + { + "identity" : "swift-sharing", + "kind" : "remoteSourceControl", + "location" : "http://github.com/pointfreeco/swift-sharing/", + "state" : { + "revision" : "0afc5170fc2d2a5f6d2ef1dd284cf4f698740603", + "version" : "1.0.1" } }, { @@ -96,8 +105,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-syntax", "state" : { - "revision" : "2bc86522d115234d1f588efe2bcb4ce4be8f8b82", - "version" : "510.0.3" + "revision" : "0687f71944021d616d34d922343dcef086855920", + "version" : "600.0.1" } }, { @@ -114,8 +123,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", "state" : { - "revision" : "770f990d3e4eececb57ac04a6076e22f8c97daeb", - "version" : "1.4.2" + "revision" : "a3f634d1a409c7979cabc0a71b3f26ffa9fc8af1", + "version" : "1.4.3" } } ], diff --git a/SyncUps/SyncUps/App.swift b/SyncUps/SyncUps/App.swift index 04f4fb9..dd2f9f0 100644 --- a/SyncUps/SyncUps/App.swift +++ b/SyncUps/SyncUps/App.swift @@ -1,181 +1,58 @@ import CasePaths import Dependencies +import IdentifiedCollections +import Sharing import SwiftUI -@MainActor -@Observable -class AppModel { - var path: [Path] { - didSet { bind() } - } - var syncUpsList: SyncUpsListModel { - didSet { bind() } - } - - @ObservationIgnored - @Dependency(\.continuousClock) var clock - @ObservationIgnored - @Dependency(\.date.now) var now - @ObservationIgnored - @Dependency(\.uuid) var uuid - - @CasePathable - @dynamicMemberLookup - enum Path: Hashable { - case detail(SyncUpDetailModel) - case meeting(Meeting, syncUp: SyncUp) - case record(RecordMeetingModel) - } - - init( - path: [Path] = [], - syncUpsList: SyncUpsListModel - ) { - self.path = path - self.syncUpsList = syncUpsList - self.bind() - } - - private func bind() { - syncUpsList.onSyncUpTapped = { [weak self] syncUp in - guard let self else { return } - withDependencies(from: self) { - self.path.append(.detail(SyncUpDetailModel(syncUp: syncUp))) - } - } - - for destination in path { - switch destination { - case let .detail(detailModel): - bindDetail(model: detailModel) - - case .meeting: - break - - case let .record(recordModel): - bindRecord(model: recordModel) - } - } - } - - private func bindDetail(model: SyncUpDetailModel) { - model.onMeetingStarted = { [weak self] syncUp in - guard let self else { return } - withDependencies(from: self) { - self.path.append( - .record( - RecordMeetingModel(syncUp: syncUp) - ) - ) - } - } - - model.onConfirmDeletion = { [weak model, weak self] in - guard let model, let self else { return } - syncUpsList.syncUps.remove(id: model.syncUp.id) - path.removeLast() - } - - model.onMeetingTapped = { [weak model, weak self] meeting in - guard let model, let self else { return } - path.append(.meeting(meeting, syncUp: model.syncUp)) - } - - model.onSyncUpUpdated = { [weak self] syncUp in - guard let self else { return } - syncUpsList.syncUps[id: syncUp.id] = syncUp - } - } - - private func bindRecord(model: RecordMeetingModel) { - model.onMeetingFinished = { [weak self] transcript in - guard let self else { return } - - guard - case let .some(.detail(detailModel)) = path.dropLast().last - else { - return - } - - let meeting = Meeting( - id: Meeting.ID(self.uuid()), - date: self.now, - transcript: transcript - ) - - let didCancel = (try? await clock.sleep(for: .milliseconds(400))) == nil - _ = withAnimation(didCancel ? nil : .default) { - detailModel.syncUp.meetings.insert(meeting, at: 0) - } - } - } -} - struct AppView: View { - @State var model: AppModel + @Shared(.path) var path var body: some View { - NavigationStack(path: $model.path) { - SyncUpsList(model: model.syncUpsList) - .navigationDestination(for: AppModel.Path.self) { destination in - switch destination { - case let .detail(detailModel): - SyncUpDetailView(model: detailModel) - case let .meeting(meeting, syncUp: syncUp): - MeetingView(meeting: meeting, syncUp: syncUp) - case let .record(recordModel): - RecordMeetingView(model: recordModel) + NavigationStack(path: Binding($path)) { + SyncUpsList() + .navigationDestination(for: AppPath.self) { path in + switch path { + case let .detail(id: syncUpID): + SyncUpDetailView(id: syncUpID) + case let .meeting(id: meetingID, syncUpID: syncUpID): + MeetingView(id: meetingID, syncUpID: syncUpID) + case let .record(id: syncUpID): + RecordMeetingView(id: syncUpID) } } } } } -struct App_Previews: PreviewProvider { - static var previews: some View { - AppView( - model: withDependencies { - $0.dataManager = .mock( - initialData: try! JSONEncoder().encode([ - SyncUp.mock, - .engineeringMock, - .designMock, - ]) - ) - } operation: { - AppModel(syncUpsList: SyncUpsListModel()) - } - ) - .previewDisplayName("Happy path") +#Preview("Happy path") { + @Shared(.syncUps) var syncUps = [ + SyncUp.mock, + .engineeringMock, + .designMock, + ] + AppView() +} - Preview( - message: """ - The preview demonstrates how you can start the application navigated to a very specific \ - screen just by constructing a piece of state. In particular we will start the app drilled \ - down to the detail screen of a sync-up, and then further drilled down to the record screen \ - for a new meeting. - """ - ) { - AppView( - model: withDependencies { - $0.dataManager = .mock( - initialData: try! JSONEncoder().encode([ - SyncUp.mock, - .engineeringMock, - .designMock, - ]) - ) - } operation: { - AppModel( - path: [ - .detail(SyncUpDetailModel(syncUp: .mock)), - .record(RecordMeetingModel(syncUp: .mock)), - ], - syncUpsList: SyncUpsListModel() - ) - } - ) - } - .previewDisplayName("Deep link record flow") +#Preview("Deep link record flow") { + let syncUp = SyncUp.mock + @Shared(.syncUps) var syncUps = [ + syncUp, + .engineeringMock, + .designMock, + ] + @Shared(.path) var path = [ + .detail(id: syncUp.id), + .record(id: syncUp.id), + ] + + Preview( + message: """ + The preview demonstrates how you can start the application navigated to a very specific \ + screen just by constructing a piece of state. In particular we will start the app drilled \ + down to the detail screen of a sync-up, and then further drilled down to the record screen \ + for a new meeting. + """ + ) { + AppView() } } diff --git a/SyncUps/SyncUps/AppPath.swift b/SyncUps/SyncUps/AppPath.swift new file mode 100644 index 0000000..e587b09 --- /dev/null +++ b/SyncUps/SyncUps/AppPath.swift @@ -0,0 +1,33 @@ +import Foundation +import Sharing + +enum AppPath: Codable, Hashable { + case detail(id: SyncUp.ID) + case meeting(id: Meeting.ID, syncUpID: SyncUp.ID) + case record(id: SyncUp.ID) + + // NB: Encode only certain paths for state restoration. + var isRestorable: Bool { + switch self { + case .detail, .meeting: true + case .record: false + } + } +} + +extension SharedReaderKey where Self == FileStorageKey<[AppPath]>.Default { + static var path: Self { + Self[ + .fileStorage( + .documentsDirectory.appending(path: "path.json"), + decode: { data in + try JSONDecoder().decode([AppPath].self, from: data) + }, + encode: { path in + try JSONEncoder().encode(path.filter(\.isRestorable)) + } + ), + default: [] + ] + } +} diff --git a/SyncUps/SyncUps/Dependencies/DataManager.swift b/SyncUps/SyncUps/Dependencies/DataManager.swift deleted file mode 100644 index f57ba19..0000000 --- a/SyncUps/SyncUps/Dependencies/DataManager.swift +++ /dev/null @@ -1,58 +0,0 @@ -import Dependencies -import DependenciesMacros -import Foundation - -@DependencyClient -struct DataManager: Sendable { - var load: @Sendable (_ from: URL) throws -> Data - var save: @Sendable (Data, _ to: URL) throws -> Void -} - -extension DataManager: DependencyKey { - static let liveValue = DataManager( - load: { url in try Data(contentsOf: url) }, - save: { data, url in try data.write(to: url) } - ) - - static let testValue = DataManager() -} - -extension DependencyValues { - var dataManager: DataManager { - get { self[DataManager.self] } - set { self[DataManager.self] = newValue } - } -} - -extension DataManager { - static func mock(initialData: Data? = nil) -> DataManager { - let data = LockIsolated(initialData) - return DataManager( - load: { _ in - guard let data = data.value - else { - struct FileNotFound: Error {} - throw FileNotFound() - } - return data - }, - save: { newData, _ in data.setValue(newData) } - ) - } - - static let failToWrite = DataManager( - load: { url in Data() }, - save: { data, url in - struct SaveError: Error {} - throw SaveError() - } - ) - - static let failToLoad = DataManager( - load: { _ in - struct LoadError: Error {} - throw LoadError() - }, - save: { newData, url in } - ) -} diff --git a/SyncUps/SyncUps/Dependencies/SpeechClient.swift b/SyncUps/SyncUps/Dependencies/SpeechClient.swift index ca2e468..3e1d761 100644 --- a/SyncUps/SyncUps/Dependencies/SpeechClient.swift +++ b/SyncUps/SyncUps/Dependencies/SpeechClient.swift @@ -143,7 +143,7 @@ private actor Speech { request: SFSpeechAudioBufferRecognitionRequest ) -> AsyncThrowingStream { AsyncThrowingStream { continuation in - self.recognitionContinuation = continuation + recognitionContinuation = continuation let audioSession = AVAudioSession.sharedInstance() do { try audioSession.setCategory(.record, mode: .measurement, options: .duckOthers) @@ -153,9 +153,9 @@ private actor Speech { return } - self.audioEngine = AVAudioEngine() + audioEngine = AVAudioEngine() let speechRecognizer = SFSpeechRecognizer(locale: Locale(identifier: "en-US"))! - self.recognitionTask = speechRecognizer.recognitionTask(with: request) { result, error in + recognitionTask = speechRecognizer.recognitionTask(with: request) { result, error in switch (result, error) { case let (.some(result), _): continuation.yield(SpeechRecognitionResult(result)) @@ -173,17 +173,17 @@ private actor Speech { recognitionTask?.finish() } - self.audioEngine?.inputNode.installTap( + audioEngine?.inputNode.installTap( onBus: 0, bufferSize: 1024, - format: self.audioEngine?.inputNode.outputFormat(forBus: 0) + format: audioEngine?.inputNode.outputFormat(forBus: 0) ) { buffer, when in request.append(buffer) } - self.audioEngine?.prepare() + audioEngine?.prepare() do { - try self.audioEngine?.start() + try audioEngine?.start() } catch { continuation.finish(throwing: error) return diff --git a/SyncUps/SyncUps/Helpers.swift b/SyncUps/SyncUps/Helpers.swift index 0acd1aa..af5feff 100644 --- a/SyncUps/SyncUps/Helpers.swift +++ b/SyncUps/SyncUps/Helpers.swift @@ -1,3 +1,4 @@ +import Sharing import SwiftUI // NB: This is only used for previews. @@ -31,17 +32,15 @@ struct Preview: View { } } -struct Preview_Previews: PreviewProvider { - static var previews: some View { - Preview( - message: - """ - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt \ - ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation \ - ullamco laboris nisi ut aliquip ex ea commodo consequat. - """ - ) { - SyncUpDetailView(model: SyncUpDetailModel(syncUp: .mock)) - } +#Preview { + Preview( + message: + """ + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt \ + ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation \ + ullamco laboris nisi ut aliquip ex ea commodo consequat. + """ + ) { + NavigationStack {} } } diff --git a/SyncUps/SyncUps/RecordMeeting.swift b/SyncUps/SyncUps/RecordMeeting.swift index b663e4d..1862611 100644 --- a/SyncUps/SyncUps/RecordMeeting.swift +++ b/SyncUps/SyncUps/RecordMeeting.swift @@ -1,6 +1,7 @@ import Clocks import Dependencies import IssueReporting +import Sharing import Speech import SwiftUI import SwiftUINavigation @@ -8,58 +9,36 @@ import SwiftUINavigation @MainActor @Observable final class RecordMeetingModel { - var destination: Destination? - var isDismissed = false + var alert: AlertState? + @ObservationIgnored @Shared(.path) var path var secondsElapsed = 0 var speakerIndex = 0 - let syncUp: SyncUp + @ObservationIgnored @Shared var syncUp: SyncUp private var transcript = "" - @ObservationIgnored - @Dependency(\.continuousClock) var clock - @ObservationIgnored - @Dependency(\.soundEffectClient) var soundEffectClient - @ObservationIgnored - @Dependency(\.speechClient) var speechClient - - var onMeetingFinished: (_ transcript: String) async -> Void = unimplemented("onMeetingFinished") - - @CasePathable - @dynamicMemberLookup - enum Destination { - case alert(AlertState) - } + @ObservationIgnored @Dependency(\.continuousClock) var clock + @ObservationIgnored @Dependency(\.date.now) var now + @ObservationIgnored @Dependency(\.soundEffectClient) var soundEffectClient + @ObservationIgnored @Dependency(\.speechClient) var speechClient + @ObservationIgnored @Dependency(\.uuid) var uuid enum AlertAction { case confirmSave case confirmDiscard } - init( - destination: Destination? = nil, - syncUp: SyncUp - ) { - self.destination = destination - self.syncUp = syncUp + init(syncUp: Shared) { + self._syncUp = syncUp } var durationRemaining: Duration { syncUp.duration - .seconds(secondsElapsed) } - var isAlertOpen: Bool { - switch destination { - case .alert: - return true - case .none: - return false - } - } - func nextButtonTapped() { guard speakerIndex < syncUp.attendees.count - 1 else { - destination = .alert(.endMeeting(isDiscardable: false)) + alert = .endMeeting(isDiscardable: false) return } @@ -69,15 +48,15 @@ final class RecordMeetingModel { } func endMeetingButtonTapped() { - destination = .alert(.endMeeting(isDiscardable: true)) + alert = .endMeeting(isDiscardable: true) } func alertButtonTapped(_ action: AlertAction?) async { switch action { - case .confirmSave?: + case .confirmSave: await finishMeeting() - case .confirmDiscard?: - isDismissed = true + case .confirmDiscard: + _ = $path.withLock { $0.removeLast() } case nil: break } @@ -115,12 +94,12 @@ final class RecordMeetingModel { if !transcript.isEmpty { transcript += " ❌" } - destination = .alert(.speechRecognizerFailed) + alert = .speechRecognizerFailed } } private func startTimer() async { - for await _ in clock.timer(interval: .seconds(1)) where !isAlertOpen { + for await _ in clock.timer(interval: .seconds(1)) where alert == nil { secondsElapsed += 1 let secondsPerAttendee = Int(syncUp.durationPerAttendee.components.seconds) @@ -136,13 +115,24 @@ final class RecordMeetingModel { } private func finishMeeting() async { - isDismissed = true - await onMeetingFinished(transcript) + _ = $path.withLock { $0.removeLast() } + + try? await clock.sleep(for: .seconds(0.4)) + _ = withAnimation { + $syncUp.withLock { + $0.meetings.insert( + Meeting( + id: Meeting.ID(uuid()), + date: now, + transcript: transcript + ), + at: 0 + ) + } + } } } -extension RecordMeetingModel: HashableObject {} - extension AlertState where Action == RecordMeetingModel.AlertAction { static func endMeeting(isDiscardable: Bool) -> Self { Self { @@ -184,46 +174,51 @@ extension AlertState where Action == RecordMeetingModel.AlertAction { struct RecordMeetingView: View { @State var model: RecordMeetingModel - @Environment(\.dismiss) var dismiss + + init?(id: SyncUp.ID) { + @Shared(.syncUps) var syncUps + guard let syncUp = Shared($syncUps[id: id]) + else { return nil } + _model = State(wrappedValue: RecordMeetingModel(syncUp: syncUp)) + } var body: some View { ZStack { RoundedRectangle(cornerRadius: 16) - .fill(self.model.syncUp.theme.mainColor) + .fill(model.syncUp.theme.mainColor) VStack { MeetingHeaderView( - secondsElapsed: self.model.secondsElapsed, - durationRemaining: self.model.durationRemaining, - theme: self.model.syncUp.theme + secondsElapsed: model.secondsElapsed, + durationRemaining: model.durationRemaining, + theme: model.syncUp.theme ) MeetingTimerView( - syncUp: self.model.syncUp, - speakerIndex: self.model.speakerIndex + syncUp: model.syncUp, + speakerIndex: model.speakerIndex ) MeetingFooterView( - syncUp: self.model.syncUp, - nextButtonTapped: { self.model.nextButtonTapped() }, - speakerIndex: self.model.speakerIndex + syncUp: model.syncUp, + nextButtonTapped: { model.nextButtonTapped() }, + speakerIndex: model.speakerIndex ) } } .padding() - .foregroundColor(self.model.syncUp.theme.accentColor) + .foregroundColor(model.syncUp.theme.accentColor) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("End meeting") { - self.model.endMeetingButtonTapped() + model.endMeetingButtonTapped() } } } .navigationBarBackButtonHidden(true) - .alert(self.$model.destination.alert) { action in - await self.model.alertButtonTapped(action) + .alert($model.alert) { action in + await model.alertButtonTapped(action) } - .task { await self.model.task() } - .onChange(of: self.model.isDismissed) { _, _ in self.dismiss() } + .task { await model.task() } } } @@ -375,31 +370,30 @@ struct MeetingFooterView: View { } } -struct RecordMeeting_Previews: PreviewProvider { - static var previews: some View { - NavigationStack { - RecordMeetingView( - model: RecordMeetingModel(syncUp: .mock) - ) - } - .previewDisplayName("Happy path") +#Preview("Happy path") { + let syncUp = SyncUp.mock + @Shared(.syncUps) var syncUps = [syncUp] + + NavigationStack { + RecordMeetingView(id: syncUp.id) + } +} - Preview( - message: """ +#Preview( + "Speech failure after 2 secs", + traits: .dependency(\.speechClient, .fail(after: .seconds(2))) +) { + let syncUp = SyncUp.mock + @Shared(.syncUps) var syncUps = [syncUp] + + Preview( + message: """ This preview demonstrates how the feature behaves when the speech recognizer emits a \ failure after 2 seconds of transcribing. """ - ) { - NavigationStack { - RecordMeetingView( - model: withDependencies { - $0.speechClient = .fail(after: .seconds(2)) - } operation: { - RecordMeetingModel(syncUp: .mock) - } - ) - } + ) { + NavigationStack { + RecordMeetingView(id: syncUp.id) } - .previewDisplayName("Speech failure after 2 secs") } } diff --git a/SyncUps/SyncUps/SyncUpDetail.swift b/SyncUps/SyncUps/SyncUpDetail.swift index 9419da0..e52d309 100644 --- a/SyncUps/SyncUps/SyncUpDetail.swift +++ b/SyncUps/SyncUps/SyncUpDetail.swift @@ -1,7 +1,9 @@ import Clocks import CustomDump import Dependencies +import IdentifiedCollections import IssueReporting +import Sharing import SwiftUI import SwiftUINavigation @@ -9,28 +11,14 @@ import SwiftUINavigation @Observable final class SyncUpDetailModel { var destination: Destination? - var isDismissed = false - var syncUp: SyncUp { - didSet { - onSyncUpUpdated(syncUp) - } - } + @ObservationIgnored @Shared(.path) var path + @ObservationIgnored @Shared var syncUp: SyncUp - @ObservationIgnored - @Dependency(\.continuousClock) var clock - @ObservationIgnored - @Dependency(\.date.now) var now - @ObservationIgnored - @Dependency(\.openSettings) var openSettings - @ObservationIgnored - @Dependency(\.speechClient.authorizationStatus) var authorizationStatus - @ObservationIgnored - @Dependency(\.uuid) var uuid - - var onConfirmDeletion: () -> Void = unimplemented("onConfirmDeletion") - var onMeetingTapped: (Meeting) -> Void = unimplemented("onMeetingTapped") - var onMeetingStarted: (SyncUp) -> Void = unimplemented("onMeetingStarted") - var onSyncUpUpdated: (SyncUp) -> Void = unimplemented("onSyncUpUpdated") + @ObservationIgnored @Dependency(\.continuousClock) var clock + @ObservationIgnored @Dependency(\.date.now) var now + @ObservationIgnored @Dependency(\.openSettings) var openSettings + @ObservationIgnored @Dependency(\.speechClient.authorizationStatus) var authorizationStatus + @ObservationIgnored @Dependency(\.uuid) var uuid @CasePathable @dynamicMemberLookup @@ -46,18 +34,14 @@ final class SyncUpDetailModel { init( destination: Destination? = nil, - syncUp: SyncUp + syncUp: Shared ) { self.destination = destination - self.syncUp = syncUp + self._syncUp = syncUp } func deleteMeetings(atOffsets indices: IndexSet) { - syncUp.meetings.remove(atOffsets: indices) - } - - func meetingTapped(_ meeting: Meeting) { - onMeetingTapped(meeting) + $syncUp.withLock { $0.meetings.remove(atOffsets: indices) } } func deleteButtonTapped() { @@ -66,14 +50,20 @@ final class SyncUpDetailModel { func alertButtonTapped(_ action: AlertAction?) async { switch action { - case .confirmDeletion?: - onConfirmDeletion() - isDismissed = true + case .confirmDeletion: + _ = $path.withLock { $0.removeLast() } + try? await clock.sleep(for: .seconds(0.4)) + @Shared(.syncUps) var syncUps + withAnimation { + _ = $syncUps.withLock { $0.remove(id: syncUp.id) } + } - case .continueWithoutRecording?: - onMeetingStarted(syncUp) + case .continueWithoutRecording: + $path.withLock { + $0.append(.record(id: syncUp.id)) + } - case .openSettings?: + case .openSettings: await openSettings() case nil: @@ -84,7 +74,7 @@ final class SyncUpDetailModel { func editButtonTapped() { destination = .edit( withDependencies(from: self) { - SyncUpFormModel(syncUp: self.syncUp) + SyncUpFormModel(syncUp: syncUp) } ) } @@ -97,14 +87,14 @@ final class SyncUpDetailModel { guard case let .edit(model) = destination else { return } - syncUp = model.syncUp + $syncUp.withLock { $0 = model.syncUp } destination = nil } func startMeetingButtonTapped() { switch authorizationStatus() { case .notDetermined, .authorized: - onMeetingStarted(syncUp) + $path.withLock { $0.append(.record(id: syncUp.id)) } case .denied: destination = .alert(.speechRecognitionDenied) @@ -118,11 +108,16 @@ final class SyncUpDetailModel { } } -extension SyncUpDetailModel: HashableObject {} - struct SyncUpDetailView: View { @State var model: SyncUpDetailModel + init?(id: SyncUp.ID) { + @Shared(.syncUps) var syncUps + guard let syncUp = Shared($syncUps[id: id]) + else { return nil } + _model = State(wrappedValue: SyncUpDetailModel(syncUp: syncUp)) + } + var body: some View { List { Section { @@ -155,9 +150,7 @@ struct SyncUpDetailView: View { if !model.syncUp.meetings.isEmpty { Section { ForEach(model.syncUp.meetings) { meeting in - Button { - model.meetingTapped(meeting) - } label: { + NavigationLink(value: AppPath.meeting(id: meeting.id, syncUpID: model.syncUp.id)) { HStack { Image(systemName: "calendar") Text(meeting.date, style: .date) @@ -277,6 +270,18 @@ struct MeetingView: View { let meeting: Meeting let syncUp: SyncUp + init?(id: Meeting.ID, syncUpID: SyncUp.ID) { + @Shared(.syncUps) var syncUps + guard + let syncUp = syncUps[id: syncUpID], + let meeting = syncUp.meetings[id: id] + else { + return nil + } + self.syncUp = syncUp + self.meeting = meeting + } + var body: some View { ScrollView { VStack(alignment: .leading) { @@ -284,92 +289,78 @@ struct MeetingView: View { .padding(.bottom) Text("Attendees") .font(.headline) - ForEach(self.syncUp.attendees) { attendee in + ForEach(syncUp.attendees) { attendee in Text(attendee.name) } Text("Transcript") .font(.headline) .padding(.top) - Text(self.meeting.transcript) + Text(meeting.transcript) } } - .navigationTitle(Text(self.meeting.date, style: .date)) + .navigationTitle(Text(meeting.date, style: .date)) .padding() } } -struct SyncUpDetail_Previews: PreviewProvider { - static var previews: some View { - Preview( - message: """ - This preview demonstrates the "happy path" of the application where everything works \ - perfectly. You can start a meeting, wait a few moments, end the meeting, and you will \ - see that a new transcription was added to the past meetings. The transcript will consist \ - of some "lorem ipsum" text because a mock speech recongizer is used for Xcode previews. - """ - ) { - NavigationStack { - SyncUpDetailView(model: SyncUpDetailModel(syncUp: .mock)) - } - } - .previewDisplayName("Happy path") - - Preview( - message: """ - This preview demonstrates an "unhappy path" of the application where the speech \ - recognizer mysteriously fails after 2 seconds of recording. This gives us an opportunity \ - to see how the application deals with this rare occurrence. To see the behavior, run the \ - preview, tap the "Start Meeting" button and wait 2 seconds. - """ - ) { - NavigationStack { - SyncUpDetailView( - model: withDependencies { - $0.speechClient = .fail(after: .seconds(2)) - } operation: { - SyncUpDetailModel(syncUp: .mock) - } - ) - } +#Preview("Happy path") { + let syncUp = SyncUp.mock + @Shared(.syncUps) var syncUps = [syncUp] + + Preview( + message: """ + This preview demonstrates the "happy path" of the application where everything works \ + perfectly. You can start a meeting, wait a few moments, end the meeting, and you will \ + see that a new transcription was added to the past meetings. The transcript will consist \ + of some "lorem ipsum" text because a mock speech recongizer is used for Xcode previews. + """ + ) { + NavigationStack { + SyncUpDetailView(id: syncUp.id) } - .previewDisplayName("Speech recognition failed") - - Preview( - message: """ - This preview demonstrates how the feature behaves when access to speech recognition has \ - been previously denied by the user. Tap the "Start Meeting" button to see how we handle \ - that situation. - """ - ) { - NavigationStack { - SyncUpDetailView( - model: withDependencies { - $0.speechClient.authorizationStatus = { .denied } - } operation: { - SyncUpDetailModel(syncUp: .mock) - } - ) - } + } +} + +#Preview( + "Speech recognition denied", + traits: .dependencies { + $0.speechClient.authorizationStatus = { .denied } + } +) { + let syncUp = SyncUp.mock + @Shared(.syncUps) var syncUps = [syncUp] + + Preview( + message: """ + This preview demonstrates how the feature behaves when access to speech recognition has \ + been previously denied by the user. Tap the "Start Meeting" button to see how we handle \ + that situation. + """ + ) { + NavigationStack { + SyncUpDetailView(id: syncUp.id) } - .previewDisplayName("Speech recognition denied") - - Preview( - message: """ - This preview demonstrates how the feature behaves when the device restricts access to \ - speech recognition APIs. Tap the "Start Meeting" button to see how we handle that \ - situation. - """ - ) { - NavigationStack { - SyncUpDetailView( - model: withDependencies { - $0.speechClient.authorizationStatus = { .restricted } - } operation: { - SyncUpDetailModel(syncUp: .mock) - } - ) - } + } +} + +#Preview( + "Speech recognition restricted", + traits: .dependencies { + $0.speechClient.authorizationStatus = { .restricted } + } +) { + let syncUp = SyncUp.mock + @Shared(.syncUps) var syncUps = [syncUp] + + Preview( + message: """ + This preview demonstrates how the feature behaves when the device restricts access to \ + speech recognition APIs. Tap the "Start Meeting" button to see how we handle that \ + situation. + """ + ) { + NavigationStack { + SyncUpDetailView(id: syncUp.id) } - .previewDisplayName("Speech recognition restricted") } } diff --git a/SyncUps/SyncUps/SyncUpForm.swift b/SyncUps/SyncUps/SyncUpForm.swift index 0afb3d0..61adf9f 100644 --- a/SyncUps/SyncUps/SyncUpForm.swift +++ b/SyncUps/SyncUps/SyncUpForm.swift @@ -7,8 +7,7 @@ final class SyncUpFormModel: Identifiable { var focus: Field? var syncUp: SyncUp - @ObservationIgnored - @Dependency(\.uuid) var uuid + @ObservationIgnored @Dependency(\.uuid) var uuid enum Field: Hashable { case attendee(Attendee.ID) @@ -21,8 +20,8 @@ final class SyncUpFormModel: Identifiable { ) { self.focus = focus self.syncUp = syncUp - if self.syncUp.attendees.isEmpty { - self.syncUp.attendees.append(Attendee(id: Attendee.ID(self.uuid()))) + if syncUp.attendees.isEmpty { + self.syncUp.attendees.append(Attendee(id: Attendee.ID(uuid()))) } } @@ -46,7 +45,7 @@ final class SyncUpFormModel: Identifiable { struct SyncUpFormView: View { @FocusState var focus: SyncUpFormModel.Field? - @State var model: SyncUpFormModel + @Bindable var model: SyncUpFormModel var body: some View { Form { diff --git a/SyncUps/SyncUps/SyncUpsApp.swift b/SyncUps/SyncUps/SyncUpsApp.swift index d9b8e1c..df2b3f8 100644 --- a/SyncUps/SyncUps/SyncUpsApp.swift +++ b/SyncUps/SyncUps/SyncUpsApp.swift @@ -1,67 +1,61 @@ import Dependencies +import IdentifiedCollections +import Sharing import SwiftUI @main struct SyncUpsApp: App { - static let model = AppModel(syncUpsList: SyncUpsListModel()) + init() { + setUpForUITest() + } var body: some Scene { WindowGroup { - // NB: This conditional is here only to facilitate UI testing so that we can mock out certain - // dependencies for the duration of the test (e.g. the data manager). We do not really - // recommend performing UI tests in general, but we do want to demonstrate how it can be - // done. - if let testName = ProcessInfo.processInfo.environment["UI_TEST_NAME"] { - UITestingView(testName: testName) - } else { - AppView(model: Self.model) - } + AppView() } } } -struct UITestingView: View { - let testName: String +//// NB: During UI tests we override certain dependencies for the app and seed initial state. +private func setUpForUITest() { + guard let testName = ProcessInfo.processInfo.environment["UI_TEST_NAME"] + else { + return + } - var body: some View { - withDependencies { - $0.continuousClock = ContinuousClock() - $0.date = DateGenerator { Date() } - $0.soundEffectClient = .noop - $0.uuid = UUIDGenerator { UUID() } - switch testName { - case "testAdd": - $0.dataManager = .mock() - case "testDelete", "testEdit": - $0.dataManager = .mock(initialData: try? JSONEncoder().encode([SyncUp.mock])) - case "testRecord", "testRecord_Discard": - $0.date = DateGenerator { Date(timeIntervalSince1970: 1_234_567_890) } - $0.speechClient.authorizationStatus = { .authorized } - $0.speechClient.startTask = { @Sendable _ in - AsyncThrowingStream { - $0.yield( - SpeechRecognitionResult( - bestTranscription: Transcription(formattedString: "Hello world!"), - isFinal: true - ) + // Set up dependencies for UI testing. + prepareDependencies { + $0.continuousClock = ContinuousClock() + $0.defaultFileStorage = .inMemory + $0.soundEffectClient = .noop + $0.uuid = UUIDGenerator { UUID() } + switch testName { + case "testAdd", "testDelete", "testEdit": + break + case "testRecord", "testRecord_Discard": + $0.date = DateGenerator { Date(timeIntervalSince1970: 1_234_567_890) } + $0.speechClient.authorizationStatus = { .authorized } + $0.speechClient.startTask = { @Sendable _ in + AsyncThrowingStream { + $0.yield( + SpeechRecognitionResult( + bestTranscription: Transcription(formattedString: "Hello world!"), + isFinal: true ) - $0.finish() - } + ) + $0.finish() } - $0.dataManager = .mock(initialData: try? JSONEncoder().encode([SyncUp.mock])) - case "testPersistence": - let id = ProcessInfo.processInfo.environment["TEST_UUID"]! - let url = URL.documentsDirectory.appending(component: "\(id).json") - $0.dataManager = .init( - load: { _ in try Data(contentsOf: url) }, - save: { data, _ in try data.write(to: url) } - ) - - default: - fatalError() } - } operation: { - AppView(model: AppModel(syncUpsList: SyncUpsListModel())) + default: + reportIssue("Unrecognized test: \(testName)") } } + + // Seed certain test cases with specific state. + switch testName { + case "testDelete", "testEdit", "testRecord", "testRecord_Discard": + @Shared(.syncUps) var syncUps = [.mock] + default: + break + } } diff --git a/SyncUps/SyncUps/SyncUpsList.swift b/SyncUps/SyncUps/SyncUpsList.swift index 0ecd5d6..5eba6d6 100644 --- a/SyncUps/SyncUps/SyncUpsList.swift +++ b/SyncUps/SyncUps/SyncUpsList.swift @@ -1,76 +1,39 @@ import Dependencies import IdentifiedCollections import IssueReporting +import Sharing import SwiftUI import SwiftUINavigation @MainActor @Observable final class SyncUpsListModel { - var destination: Destination? - var syncUps: IdentifiedArrayOf { - didSet { - saveDebouncedTask?.cancel() - saveDebouncedTask = Task { - try await clock.sleep(for: .seconds(1)) - try dataManager.save(JSONEncoder().encode(syncUps), to: .syncUps) - } - } - } - private var saveDebouncedTask: Task? + var addSyncUp: SyncUpFormModel? + @ObservationIgnored @Shared(.syncUps) var syncUps - @ObservationIgnored - @Dependency(\.continuousClock) var clock - @ObservationIgnored - @Dependency(\.dataManager) var dataManager - @ObservationIgnored - @Dependency(\.uuid) var uuid - - var onSyncUpTapped: (SyncUp) -> Void = unimplemented("onSyncUpTapped") - - @CasePathable - @dynamicMemberLookup - enum Destination { - case add(SyncUpFormModel) - case alert(AlertState) - } - enum AlertAction { - case confirmLoadMockData - } + @ObservationIgnored @Dependency(\.continuousClock) var clock + @ObservationIgnored @Dependency(\.uuid) var uuid init( - destination: Destination? = nil + addSyncUp: SyncUpFormModel? = nil ) { - self.destination = destination - self.syncUps = [] - - do { - self.syncUps = try JSONDecoder().decode( - IdentifiedArray.self, - from: self.dataManager.load(from: .syncUps) - ) - } catch is DecodingError { - self.destination = .alert(.dataFailedToLoad) - } catch { - } + self.addSyncUp = addSyncUp } func addSyncUpButtonTapped() { - destination = .add( - withDependencies(from: self) { - SyncUpFormModel(syncUp: SyncUp(id: SyncUp.ID(self.uuid()))) - } - ) + addSyncUp = withDependencies(from: self) { + SyncUpFormModel(syncUp: SyncUp(id: SyncUp.ID(uuid()))) + } } func dismissAddSyncUpButtonTapped() { - destination = nil + addSyncUp = nil } func confirmAddSyncUpButtonTapped() { - defer { destination = nil } + defer { addSyncUp = nil } - guard case let .add(syncUpFormModel) = destination + guard let syncUpFormModel = addSyncUp else { return } var syncUp = syncUpFormModel.syncUp @@ -80,58 +43,17 @@ final class SyncUpsListModel { if syncUp.attendees.isEmpty { syncUp.attendees.append(Attendee(id: Attendee.ID(uuid()))) } - syncUps.append(syncUp) - } - - func syncUpTapped(syncUp: SyncUp) { - onSyncUpTapped(syncUp) - } - - func alertButtonTapped(_ action: AlertAction?) { - switch action { - case .confirmLoadMockData?: - withAnimation { - syncUps = [ - .mock, - .designMock, - .engineeringMock, - ] - } - case nil: - break - } - } -} - -extension AlertState where Action == SyncUpsListModel.AlertAction { - static let dataFailedToLoad = Self { - TextState("Data failed to load") - } actions: { - ButtonState(action: .confirmLoadMockData) { - TextState("Yes") - } - ButtonState(role: .cancel) { - TextState("No") - } - } message: { - TextState( - """ - Unfortunately your past data failed to load. Would you like to load some mock data to play \ - around with? - """ - ) + _ = $syncUps.withLock { $0.append(syncUp) } } } struct SyncUpsList: View { - @Bindable var model: SyncUpsListModel + @State var model = SyncUpsListModel() var body: some View { List { - ForEach(self.model.syncUps) { syncUp in - Button { - self.model.syncUpTapped(syncUp: syncUp) - } label: { + ForEach(model.syncUps) { syncUp in + NavigationLink(value: AppPath.detail(id: syncUp.id)) { CardView(syncUp: syncUp) } .listRowBackground(syncUp.theme.mainColor) @@ -139,33 +61,30 @@ struct SyncUpsList: View { } .toolbar { Button { - self.model.addSyncUpButtonTapped() + model.addSyncUpButtonTapped() } label: { Image(systemName: "plus") } } .navigationTitle("Daily Sync-ups") - .sheet(item: self.$model.destination.add) { model in + .sheet(item: $model.addSyncUp) { syncUpFormModel in NavigationStack { - SyncUpFormView(model: model) + SyncUpFormView(model: syncUpFormModel) .navigationTitle("New sync-up") .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Dismiss") { - self.model.dismissAddSyncUpButtonTapped() + model.dismissAddSyncUpButtonTapped() } } ToolbarItem(placement: .confirmationAction) { Button("Add") { - self.model.confirmAddSyncUpButtonTapped() + model.confirmAddSyncUpButtonTapped() } } } } } - .alert(self.$model.destination.alert) { - self.model.alertButtonTapped($0) - } } } @@ -174,19 +93,19 @@ struct CardView: View { var body: some View { VStack(alignment: .leading) { - Text(self.syncUp.title) + Text(syncUp.title) .font(.headline) Spacer() HStack { - Label("\(self.syncUp.attendees.count)", systemImage: "person.3") + Label("\(syncUp.attendees.count)", systemImage: "person.3") Spacer() - Label(self.syncUp.duration.formatted(.units()), systemImage: "clock") + Label(syncUp.duration.formatted(.units()), systemImage: "clock") .labelStyle(.trailingIcon) } .font(.caption) } .padding() - .foregroundColor(self.syncUp.theme.accentColor) + .foregroundColor(syncUp.theme.accentColor) } } @@ -203,80 +122,52 @@ extension LabelStyle where Self == TrailingIconLabelStyle { static var trailingIcon: Self { Self() } } -extension URL { - fileprivate static let syncUps = Self.documentsDirectory.appending(component: "sync-ups.json") +extension SharedReaderKey where Self == FileStorageKey>.Default { + static var syncUps: Self { + Self[.fileStorage(URL.documentsDirectory.appending(component: "sync-ups.json")), default: []] + } } -struct SyncUpsList_Previews: PreviewProvider { - static var previews: some View { - Preview( - message: """ - This preview demonstrates how to start the app in a state with a few sync-ups \ - pre-populated. Since the initial sync-ups are loaded from disk we cannot simply pass some \ - data to the SyncUpsList model. But, we can override the DataManager dependency so that \ - when its load endpoint is called it will load whatever data we want. - """ - ) { - SyncUpsList( - model: withDependencies { - $0.dataManager = .mock( - initialData: try! JSONEncoder().encode([ - SyncUp.mock, - .engineeringMock, - .designMock, - ]) - ) - } operation: { - SyncUpsListModel() - } - ) - } - .previewDisplayName("Mocking initial sync-ups") - - Preview( - message: """ - This preview demonstrates how to test the flow of loading bad data from disk, in which \ - case an alert should be shown. This can be done by overridding the DataManager dependency \ - so that its initial data does not properly decode into a collection of sync-ups. - """ - ) { - SyncUpsList( - model: withDependencies { - $0.dataManager = .mock( - initialData: Data("!@#$% bad data ^&*()".utf8) - ) - } operation: { - SyncUpsListModel() - } - ) +#Preview("Mocking initial sync-ups") { + Preview( + message: """ + This preview demonstrates how to start the app in a state with a few sync-ups \ + pre-populated. Since the initial sync-ups are loaded from disk we cannot simply pass some \ + data to the SyncUpsList model. But, we can override the DataManager dependency so that \ + when its load endpoint is called it will load whatever data we want. + """ + ) { + @Shared(.syncUps) var syncUps: IdentifiedArray = [ + SyncUp.mock, + .engineeringMock, + .designMock, + ] + NavigationStack { + SyncUpsList(model: SyncUpsListModel()) } - .previewDisplayName("Load data failure") + } +} - Preview( - message: """ - The preview demonstrates how you can start the application navigated to a very specific \ - screen just by constructing a piece of state. In particular we will start the app with the \ - "Add sync-up" screen opened and with the last attendee text field focused. - """ - ) { +#Preview("Deep link add flow") { + Preview( + message: """ + The preview demonstrates how you can start the application navigated to a very specific \ + screen just by constructing a piece of state. In particular we will start the app with the \ + "Add sync-up" screen opened and with the last attendee text field focused. + """ + ) { + var syncUp = SyncUp.mock + let lastAttendee = Attendee(id: Attendee.ID()) + let _ = syncUp.attendees.append(lastAttendee) + NavigationStack { SyncUpsList( - model: withDependencies { - $0.dataManager = .mock() - } operation: { - var syncUp = SyncUp.mock - let lastAttendee = Attendee(id: Attendee.ID()) - let _ = syncUp.attendees.append(lastAttendee) - return SyncUpsListModel( - destination: .add( - SyncUpFormModel( - focus: .attendee(lastAttendee.id), - syncUp: syncUp - ) - ) + model: SyncUpsListModel( + addSyncUp: SyncUpFormModel( + focus: .attendee(lastAttendee.id), + syncUp: syncUp ) - } + ) ) } - .previewDisplayName("Deep link add flow") } } diff --git a/SyncUps/SyncUpsTests/AppTests.swift b/SyncUps/SyncUpsTests/AppTests.swift deleted file mode 100644 index 661aeb9..0000000 --- a/SyncUps/SyncUpsTests/AppTests.swift +++ /dev/null @@ -1,137 +0,0 @@ -import CasePaths -import CustomDump -import Dependencies -import Foundation -import Testing - -@testable import SyncUps - -@MainActor -@Suite -struct AppTests { - @Test - func recordingWithTranscript() async throws { - let syncUp = SyncUp( - id: SyncUp.ID(), - attendees: [ - .init(id: Attendee.ID()), - .init(id: Attendee.ID()), - ], - duration: .seconds(10), - title: "Engineering" - ) - - let model = withDependencies { - $0.continuousClock = ImmediateClock() - $0.date.now = Date(timeIntervalSince1970: 1_234_567_890) - $0.dataManager = .mock(initialData: try? JSONEncoder().encode([syncUp])) - $0.soundEffectClient = .noop - $0.speechClient.authorizationStatus = { .authorized } - $0.speechClient.startTask = { @Sendable _ in - AsyncThrowingStream { continuation in - continuation.yield( - SpeechRecognitionResult( - bestTranscription: Transcription(formattedString: "I completed the project"), - isFinal: true - ) - ) - continuation.finish() - } - } - $0.uuid = .incrementing - } operation: { - AppModel( - path: [ - .detail(SyncUpDetailModel(syncUp: syncUp)), - .record(RecordMeetingModel(syncUp: syncUp)), - ], - syncUpsList: SyncUpsListModel() - ) - } - - let recordModel = try #require(model.path[1].record) - await recordModel.task() - - expectNoDifference( - model.syncUpsList.syncUps[0].meetings, - [ - Meeting( - id: Meeting.ID(uuidString: "00000000-0000-0000-0000-000000000000")!, - date: Date(timeIntervalSince1970: 1_234_567_890), - transcript: "I completed the project" - ) - ] - ) - } - - @Test - func delete() async throws { - let model = try withDependencies { - $0.continuousClock = ImmediateClock() - $0.dataManager = .mock( - initialData: try JSONEncoder().encode([SyncUp.mock]) - ) - } operation: { - AppModel(syncUpsList: SyncUpsListModel()) - } - - model.syncUpsList.syncUpTapped(syncUp: model.syncUpsList.syncUps[0]) - - let detailModel = try #require(model.path[0].detail) - - detailModel.deleteButtonTapped() - - let alert = try #require(detailModel.destination?.alert) - - expectNoDifference(alert, .deleteSyncUp) - - await detailModel.alertButtonTapped(.confirmDeletion) - - expectNoDifference(model.path, []) - expectNoDifference(model.syncUpsList.syncUps, []) - } - - @Test - func detailEdit() async throws { - let model = try withDependencies { - $0.continuousClock = ImmediateClock() - $0.dataManager = .mock( - initialData: try JSONEncoder().encode([ - SyncUp( - id: SyncUp.ID(uuidString: "00000000-0000-0000-0000-000000000000")!, - attendees: [ - Attendee(id: Attendee.ID(uuidString: "00000000-0000-0000-0000-000000000001")!) - ] - ) - ]) - ) - } operation: { - AppModel(syncUpsList: SyncUpsListModel()) - } - - model.syncUpsList.syncUpTapped(syncUp: model.syncUpsList.syncUps[0]) - - let detailModel = try #require(model.path[0].detail) - - detailModel.editButtonTapped() - - let editModel = try #require(detailModel.destination?.edit) - - editModel.syncUp.title = "Design" - detailModel.doneEditingButtonTapped() - - #expect(detailModel.destination == nil) - expectNoDifference( - model.syncUpsList.syncUps, - [ - SyncUp( - id: SyncUp.ID(uuidString: "00000000-0000-0000-0000-000000000000")!, - attendees: [ - Attendee(id: Attendee.ID(uuidString: "00000000-0000-0000-0000-000000000001")!) - ], - title: "Design" - ) - ] - ) - } -} diff --git a/SyncUps/SyncUpsTests/RecordMeetingTests.swift b/SyncUps/SyncUpsTests/RecordMeetingTests.swift index 4a0d2fc..793c8ad 100644 --- a/SyncUps/SyncUpsTests/RecordMeetingTests.swift +++ b/SyncUps/SyncUpsTests/RecordMeetingTests.swift @@ -1,81 +1,90 @@ import CasePaths import CustomDump import Dependencies +import Foundation +import Sharing import Testing @testable import SyncUps @MainActor -@Suite -struct RecordMeetingTests { - @Test - func timer() async throws { - let clock = TestClock() +@Suite struct RecordMeetingTests { + let clock = TestClock() + @Shared(.path) var path + + @Test func timer() async throws { let soundEffectPlayCount = LockIsolated(0) + let syncUp = SyncUp( + id: SyncUp.ID(), + attendees: [ + Attendee(id: Attendee.ID()), + Attendee(id: Attendee.ID()), + Attendee(id: Attendee.ID()), + ], + duration: .seconds(3) + ) + $path.withLock { $0 = [.record(id: syncUp.id)] } let model = withDependencies { $0.continuousClock = clock + $0.date.now = Date(timeIntervalSince1970: 1234567890) $0.soundEffectClient = .noop $0.soundEffectClient.play = { soundEffectPlayCount.withValue { $0 += 1 } } $0.speechClient.authorizationStatus = { .denied } + $0.uuid = .incrementing } operation: { RecordMeetingModel( - syncUp: SyncUp( - id: SyncUp.ID(), - attendees: [ - Attendee(id: Attendee.ID()), - Attendee(id: Attendee.ID()), - Attendee(id: Attendee.ID()), - ], - duration: .seconds(3) + syncUp: Shared( + value: syncUp ) ) } - try await confirmation { confirmation in - model.onMeetingFinished = { - #expect($0 == "") - confirmation() - } - - let task = Task { - await model.task() - } + let task = Task { + await model.task() + } - // NB: This should not be necessary, but it doesn't seem like there is a better way to - // guarantee that the timer has started up. See this forum discussion for more information - // on the difficulties of testing async code in Swift: - // https://forums.swift.org/t/reliably-testing-code-that-adopts-swift-concurrency/57304 - try await Task.sleep(for: .milliseconds(300)) + // NB: This should not be necessary, but it doesn't seem like there is a better way to + // guarantee that the timer has started up. See this forum discussion for more information + // on the difficulties of testing async code in Swift: + // https://forums.swift.org/t/reliably-testing-code-that-adopts-swift-concurrency/57304 + try await Task.sleep(for: .milliseconds(300)) - #expect(model.speakerIndex == 0) - #expect(model.durationRemaining == .seconds(3)) + #expect(model.speakerIndex == 0) + #expect(model.durationRemaining == .seconds(3)) - await clock.advance(by: .seconds(1)) - #expect(model.speakerIndex == 1) - #expect(model.durationRemaining == .seconds(2)) - #expect(soundEffectPlayCount.value == 1) + await clock.advance(by: .seconds(1)) + #expect(model.speakerIndex == 1) + #expect(model.durationRemaining == .seconds(2)) + #expect(soundEffectPlayCount.value == 1) - await clock.advance(by: .seconds(1)) - #expect(model.speakerIndex == 2) - #expect(model.durationRemaining == .seconds(1)) - #expect(soundEffectPlayCount.value == 2) + await clock.advance(by: .seconds(1)) + #expect(model.speakerIndex == 2) + #expect(model.durationRemaining == .seconds(1)) + #expect(soundEffectPlayCount.value == 2) - await clock.advance(by: .seconds(1)) - #expect(model.speakerIndex == 2) - #expect(model.durationRemaining == .seconds(0)) - #expect(soundEffectPlayCount.value == 2) + await clock.advance(by: .seconds(1)) + #expect(model.speakerIndex == 2) + #expect(model.durationRemaining == .seconds(0)) + #expect(soundEffectPlayCount.value == 2) - await task.value + await clock.run() + await task.value - #expect(soundEffectPlayCount.value == 2) - } + #expect(soundEffectPlayCount.value == 2) } - @Test - func recordTranscript() async throws { + @Test func recordTranscript() async throws { + let syncUp = SyncUp( + id: SyncUp.ID(), + attendees: [Attendee(id: Attendee.ID())], + duration: .seconds(3) + ) + $path.withLock { $0 = [.record(id: syncUp.id)] } + let model = withDependencies { $0.continuousClock = ImmediateClock() + $0.date.now = Date(timeIntervalSince1970: 1234567890) $0.soundEffectClient = .noop $0.speechClient.authorizationStatus = { .authorized } $0.speechClient.startTask = { @Sendable _ in @@ -89,76 +98,78 @@ struct RecordMeetingTests { continuation.finish() } } + $0.uuid = .incrementing } operation: { RecordMeetingModel( - syncUp: SyncUp( - id: SyncUp.ID(), - attendees: [Attendee(id: Attendee.ID())], - duration: .seconds(3) - ) + syncUp: Shared(value: syncUp) ) } - await confirmation { confirmation in - model.onMeetingFinished = { - #expect($0 == "I completed the project") - confirmation() - } + await model.task() - await model.task() - } + expectNoDifference( + model.syncUp.meetings, + [ + Meeting( + id: Meeting.ID(UUID(0)), + date: Date(timeIntervalSince1970: 1234567890), + transcript: "I completed the project" + ) + ] + ) } - @Test - func endMeetingSave() async throws { - let clock = TestClock() + @Test func endMeetingSave() async throws { + let syncUp = SyncUp.mock + $path.withLock { $0 = [.detail(id: syncUp.id), .record(id: syncUp.id)] } let model = withDependencies { $0.continuousClock = clock + $0.date.now = Date(timeIntervalSince1970: 1234567890) $0.soundEffectClient = .noop $0.speechClient.authorizationStatus = { .denied } + $0.uuid = .incrementing } operation: { - RecordMeetingModel(syncUp: .mock) + RecordMeetingModel(syncUp: Shared(value: syncUp)) } - try await confirmation { confirmation in - model.onMeetingFinished = { - #expect($0 == "") - confirmation() - } - - let task = Task { - await model.task() - } + let task = Task { + await model.task() + } - model.endMeetingButtonTapped() + model.endMeetingButtonTapped() - let alert = try #require(model.destination?.alert) + let alert = try #require(model.alert) - expectNoDifference(alert, .endMeeting(isDiscardable: true)) + expectNoDifference(alert, .endMeeting(isDiscardable: true)) - await clock.advance(by: .seconds(5)) + await clock.advance(by: .seconds(5)) - #expect(model.speakerIndex == 0) - #expect(model.durationRemaining == .seconds(60)) + #expect(model.speakerIndex == 0) + #expect(model.durationRemaining == .seconds(60)) + let saveTask = Task { await model.alertButtonTapped(.confirmSave) - - task.cancel() - await task.value } + try await Task.sleep(for: .seconds(0.1)) + await clock.advance(by: .seconds(0.4)) + await saveTask.value + #expect(path == [.detail(id: syncUp.id)]) + + task.cancel() + await task.value } - @Test - func endMeetingDiscard() async throws { - let clock = TestClock() + @Test func endMeetingDiscard() async throws { + let syncUp = SyncUp.mock + $path.withLock { $0 = [.detail(id: syncUp.id), .record(id: syncUp.id)] } let model = withDependencies { $0.continuousClock = clock $0.soundEffectClient = .noop $0.speechClient.authorizationStatus = { .denied } } operation: { - RecordMeetingModel(syncUp: .mock) + RecordMeetingModel(syncUp: Shared(value: syncUp)) } let task = Task { @@ -167,7 +178,7 @@ struct RecordMeetingTests { model.endMeetingButtonTapped() - let alert = try #require(model.destination?.alert) + let alert = try #require(model.alert) expectNoDifference(alert, .endMeeting(isDiscardable: true)) @@ -175,81 +186,85 @@ struct RecordMeetingTests { task.cancel() await task.value - #expect(model.isDismissed == true) + #expect(path == [.detail(id: syncUp.id)]) } - @Test - func nextSpeaker() async throws { - let clock = TestClock() + @Test func nextSpeaker() async throws { + let syncUp = SyncUp( + id: SyncUp.ID(), + attendees: [ + Attendee(id: Attendee.ID()), + Attendee(id: Attendee.ID()), + Attendee(id: Attendee.ID()), + ], + duration: .seconds(3) + ) let soundEffectPlayCount = LockIsolated(0) + $path.withLock { $0 = [.record(id: syncUp.id)] } + print(path) let model = withDependencies { - $0.continuousClock = clock + $0.continuousClock = ImmediateClock() + $0.date.now = Date(timeIntervalSince1970: 1234567890) $0.soundEffectClient = .noop $0.soundEffectClient.play = { soundEffectPlayCount.withValue { $0 += 1 } } $0.speechClient.authorizationStatus = { .denied } - + $0.uuid = .incrementing } operation: { RecordMeetingModel( - syncUp: SyncUp( - id: SyncUp.ID(), - attendees: [ - Attendee(id: Attendee.ID()), - Attendee(id: Attendee.ID()), - Attendee(id: Attendee.ID()), - ], - duration: .seconds(3) + syncUp: Shared( + value: syncUp ) ) } - try await confirmation { confirmation in - model.onMeetingFinished = { - #expect($0 == "") - confirmation() - } + let task = Task { + await model.task() + } - let task = Task { - await model.task() - } + model.nextButtonTapped() - model.nextButtonTapped() + #expect(model.speakerIndex == 1) + #expect(model.durationRemaining == .seconds(2)) + #expect(soundEffectPlayCount.value == 1) - #expect(model.speakerIndex == 1) - #expect(model.durationRemaining == .seconds(2)) - #expect(soundEffectPlayCount.value == 1) + model.nextButtonTapped() - model.nextButtonTapped() + #expect(model.speakerIndex == 2) + #expect(model.durationRemaining == .seconds(1)) + #expect(soundEffectPlayCount.value == 2) - #expect(model.speakerIndex == 2) - #expect(model.durationRemaining == .seconds(1)) - #expect(soundEffectPlayCount.value == 2) + model.nextButtonTapped() - model.nextButtonTapped() + let alert = try #require(model.alert) - let alert = try #require(model.destination?.alert) + expectNoDifference(alert, .endMeeting(isDiscardable: false)) - expectNoDifference(alert, .endMeeting(isDiscardable: false)) + await clock.advance(by: .seconds(5)) - await clock.advance(by: .seconds(5)) + #expect(model.speakerIndex == 2) + #expect(model.durationRemaining == .seconds(1)) + #expect(soundEffectPlayCount.value == 2) - #expect(model.speakerIndex == 2) - #expect(model.durationRemaining == .seconds(1)) - #expect(soundEffectPlayCount.value == 2) + await model.alertButtonTapped(.confirmSave) - await model.alertButtonTapped(.confirmSave) + #expect(soundEffectPlayCount.value == 2) - #expect(soundEffectPlayCount.value == 2) - - task.cancel() - await task.value - } + task.cancel() + await task.value } - @Test - func speechRecognitionFailure_Continue() async throws { + @Test func speechRecognitionFailure_Continue() async throws { + let syncUp = SyncUp( + id: SyncUp.ID(), + attendees: [Attendee(id: Attendee.ID())], + duration: .seconds(3) + ) + $path.withLock { $0 = [.record(id: syncUp.id)] } + let model = withDependencies { $0.continuousClock = ImmediateClock() + $0.date.now = Date(timeIntervalSince1970: 1234567890) $0.soundEffectClient = .noop $0.speechClient.authorizationStatus = { .authorized } $0.speechClient.startTask = { @Sendable _ in @@ -264,46 +279,42 @@ struct RecordMeetingTests { $0.finish(throwing: SpeechRecognitionFailure()) } } + $0.uuid = .incrementing } operation: { RecordMeetingModel( - syncUp: SyncUp( - id: SyncUp.ID(), - attendees: [Attendee(id: Attendee.ID())], - duration: .seconds(3) + syncUp: Shared( + value: syncUp ) ) } - try await confirmation { confirmation in - model.onMeetingFinished = { transcript in - #expect(transcript == "I completed the project ❌") - confirmation() - } - - let task = Task { - await model.task() - } + let task = Task { + await model.task() + } - // NB: This should not be necessary, but it doesn't seem like there is a better way to - // guarantee that the timer has started up. See this forum discussion for more information - // on the difficulties of testing async code in Swift: - // https://forums.swift.org/t/reliably-testing-code-that-adopts-swift-concurrency/57304 - try await Task.sleep(for: .milliseconds(100)) + // NB: This should not be necessary, but it doesn't seem like there is a better way to + // guarantee that the timer has started up. See this forum discussion for more information + // on the difficulties of testing async code in Swift: + // https://forums.swift.org/t/reliably-testing-code-that-adopts-swift-concurrency/57304 + try await Task.sleep(for: .milliseconds(100)) - let alert = try #require(model.destination?.alert) - #expect(alert == .speechRecognizerFailed) + let alert = try #require(model.alert) + #expect(alert == .speechRecognizerFailed) - model.destination = nil // NB: Simulate SwiftUI closing alert. + model.alert = nil // NB: Simulate SwiftUI closing alert. - await task.value + await task.value - #expect(model.secondsElapsed == 3) - } + #expect(model.secondsElapsed == 3) } - @Test - func speechRecognitionFailure_Discard() async throws { - let clock = TestClock() + @Test func speechRecognitionFailure_Discard() async throws { + let syncUp = SyncUp( + id: SyncUp.ID(), + attendees: [Attendee(id: Attendee.ID())], + duration: .seconds(3) + ) + $path.withLock { $0 = [.detail(id: syncUp.id), .record(id: syncUp.id)] } let model = withDependencies { $0.continuousClock = clock @@ -314,13 +325,7 @@ struct RecordMeetingTests { return AsyncThrowingStream.finished(throwing: SpeechRecognitionFailure()) } } operation: { - RecordMeetingModel( - syncUp: SyncUp( - id: SyncUp.ID(), - attendees: [Attendee(id: Attendee.ID())], - duration: .seconds(3) - ) - ) + RecordMeetingModel(syncUp: Shared(value: syncUp)) } Task { @@ -333,12 +338,12 @@ struct RecordMeetingTests { // https://forums.swift.org/t/reliably-testing-code-that-adopts-swift-concurrency/57304 try await Task.sleep(for: .milliseconds(100)) - let alert = try #require(model.destination?.alert) + let alert = try #require(model.alert) #expect(alert == .speechRecognizerFailed) await model.alertButtonTapped(.confirmDiscard) - model.destination = nil // NB: Simulate SwiftUI closing alert. + model.alert = nil // NB: Simulate SwiftUI closing alert. - #expect(model.isDismissed == true) + #expect(path == [.detail(id: syncUp.id)]) } } diff --git a/SyncUps/SyncUpsTests/SyncUpDetailTests.swift b/SyncUps/SyncUpsTests/SyncUpDetailTests.swift index c34853c..cc385e4 100644 --- a/SyncUps/SyncUpsTests/SyncUpDetailTests.swift +++ b/SyncUps/SyncUpsTests/SyncUpDetailTests.swift @@ -1,6 +1,8 @@ import CasePaths import CustomDump import Dependencies +import DependenciesTestSupport +import Sharing import Testing @testable import SyncUps @@ -8,12 +10,13 @@ import Testing @MainActor @Suite struct SyncUpDetailTests { - @Test - func speechRestricted() async throws { + @Shared(.path) var path + + @Test func speechRestricted() async throws { let model = withDependencies { $0.speechClient.authorizationStatus = { .restricted } } operation: { - SyncUpDetailModel(syncUp: .mock) + SyncUpDetailModel(syncUp: Shared(value: .mock)) } model.startMeetingButtonTapped() @@ -23,12 +26,11 @@ struct SyncUpDetailTests { expectNoDifference(alert, .speechRecognitionRestricted) } - @Test - func speechDenied() async throws { + @Test func speechDenied() async throws { let model = withDependencies { $0.speechClient.authorizationStatus = { .denied } } operation: { - SyncUpDetailModel(syncUp: .mock) + SyncUpDetailModel(syncUp: Shared(value: .mock)) } model.startMeetingButtonTapped() @@ -38,15 +40,14 @@ struct SyncUpDetailTests { expectNoDifference(alert, .speechRecognitionDenied) } - @Test - func openSettings() async { + @Test func openSettings() async { let settingsOpened = LockIsolated(false) let model = withDependencies { $0.openSettings = { settingsOpened.setValue(true) } } operation: { SyncUpDetailModel( destination: .alert(.speechRecognitionDenied), - syncUp: .mock + syncUp: Shared(value: .mock) ) } @@ -55,78 +56,86 @@ struct SyncUpDetailTests { #expect(settingsOpened.value == true) } - @Test - func continueWithoutRecording() async throws { + @Test func continueWithoutRecording() async throws { + let syncUp = SyncUp.mock + let model = SyncUpDetailModel( destination: .alert(.speechRecognitionDenied), - syncUp: .mock + syncUp: Shared(value: syncUp) ) - await confirmation { confirmation in - model.onMeetingStarted = { syncUp in - #expect(syncUp == .mock) - confirmation() - } + await model.alertButtonTapped(.continueWithoutRecording) - await model.alertButtonTapped(.continueWithoutRecording) - } + #expect(path == [.record(id: syncUp.id)]) } - @Test - func speechAuthorized() async throws { + @Test func speechAuthorized() async throws { + let syncUp = SyncUp.mock + let model = withDependencies { $0.speechClient.authorizationStatus = { .authorized } } operation: { - SyncUpDetailModel(syncUp: .mock) + SyncUpDetailModel(syncUp: Shared(value: syncUp)) } - await confirmation { confirmation in - model.onMeetingStarted = { syncUp in - #expect(syncUp == .mock) - confirmation() - } + model.startMeetingButtonTapped() - model.startMeetingButtonTapped() - } + #expect(path == [.record(id: syncUp.id)]) } - @Test + @Test(.dependency(\.uuid, .incrementing)) func edit() async throws { - let model = withDependencies { - $0.uuid = .incrementing - } operation: { - @Dependency(\.uuid) var uuid - - return SyncUpDetailModel( - syncUp: SyncUp( + @Dependency(\.uuid) var uuid + let model = SyncUpDetailModel( + syncUp: Shared( + value: SyncUp( id: SyncUp.ID(uuid()), title: "Engineering" ) ) - } + ) - try await confirmation { confirmation in - model.onSyncUpUpdated = { _ in confirmation() } - - model.editButtonTapped() - - let editModel = try #require(model.destination?.edit) - editModel.syncUp.title = "Engineering" - editModel.syncUp.theme = .lavender - model.doneEditingButtonTapped() - - #expect(model.destination == nil) - expectNoDifference( - model.syncUp, - SyncUp( - id: SyncUp.ID(uuidString: "00000000-0000-0000-0000-000000000000")!, - attendees: [ - Attendee(id: Attendee.ID(uuidString: "00000000-0000-0000-0000-000000000001")!) - ], - theme: .lavender, - title: "Engineering" - ) + model.editButtonTapped() + + let editModel = try #require(model.destination?.edit) + editModel.syncUp.title = "Engineering" + editModel.syncUp.theme = .lavender + model.doneEditingButtonTapped() + + #expect(model.destination == nil) + expectNoDifference( + model.syncUp, + SyncUp( + id: SyncUp.ID(uuidString: "00000000-0000-0000-0000-000000000000")!, + attendees: [ + Attendee(id: Attendee.ID(uuidString: "00000000-0000-0000-0000-000000000001")!) + ], + theme: .lavender, + title: "Engineering" ) + ) + } + + @Test func delete() async { + let syncUp = SyncUp.mock + @Shared(.syncUps) var syncUps = [syncUp] + $path.withLock { $0 = [.detail(id: syncUp.id)] } + + let settingsOpened = LockIsolated(false) + let model = withDependencies { + $0.continuousClock = ContinuousClock() + $0.openSettings = { settingsOpened.setValue(true) } + } operation: { + SyncUpDetailModel(syncUp: $syncUps[0]) } + + model.deleteButtonTapped() + + #expect(model.destination?.alert == .deleteSyncUp) + + await model.alertButtonTapped(.confirmDeletion) + + #expect(syncUps == []) + #expect(path == []) } } diff --git a/SyncUps/SyncUpsTests/SyncUpFormTests.swift b/SyncUps/SyncUpsTests/SyncUpFormTests.swift index edddc8b..7fbe95b 100644 --- a/SyncUps/SyncUpsTests/SyncUpFormTests.swift +++ b/SyncUps/SyncUpsTests/SyncUpFormTests.swift @@ -5,21 +5,16 @@ import Testing @testable import SyncUps @MainActor -@Suite +@Suite(.dependency(\.uuid, .incrementing)) struct SyncUpFormTests { - @Test - func addAttendee() async { - let model = withDependencies { - $0.uuid = .incrementing - } operation: { - SyncUpFormModel( - syncUp: SyncUp( - id: SyncUp.ID(), - attendees: [], - title: "Engineering" - ) + @Test func addAttendee() async { + let model = SyncUpFormModel( + syncUp: SyncUp( + id: SyncUp.ID(), + attendees: [], + title: "Engineering" ) - } + ) expectNoDifference( model.syncUp.attendees, @@ -39,19 +34,14 @@ struct SyncUpFormTests { ) } - @Test - func focusAddAttendee() async { - let model = withDependencies { - $0.uuid = .incrementing - } operation: { - SyncUpFormModel( - syncUp: SyncUp( - id: SyncUp.ID(), - attendees: [], - title: "Engineering" - ) + @Test func focusAddAttendee() async { + let model = SyncUpFormModel( + syncUp: SyncUp( + id: SyncUp.ID(), + attendees: [], + title: "Engineering" ) - } + ) #expect(model.focus == .title) @@ -62,26 +52,20 @@ struct SyncUpFormTests { ) } - @Test - func focusRemoveAttendee() async { - let model = withDependencies { - $0.uuid = .incrementing - } operation: { - @Dependency(\.uuid) var uuid - - return SyncUpFormModel( - syncUp: SyncUp( - id: SyncUp.ID(), - attendees: [ - Attendee(id: Attendee.ID(uuid())), - Attendee(id: Attendee.ID(uuid())), - Attendee(id: Attendee.ID(uuid())), - Attendee(id: Attendee.ID(uuid())), - ], - title: "Engineering" - ) + @Test func focusRemoveAttendee() async { + @Dependency(\.uuid) var uuid + let model = SyncUpFormModel( + syncUp: SyncUp( + id: SyncUp.ID(), + attendees: [ + Attendee(id: Attendee.ID(uuid())), + Attendee(id: Attendee.ID(uuid())), + Attendee(id: Attendee.ID(uuid())), + Attendee(id: Attendee.ID(uuid())), + ], + title: "Engineering" ) - } + ) model.deleteAttendees(atOffsets: [0]) diff --git a/SyncUps/SyncUpsTests/SyncUpsListTests.swift b/SyncUps/SyncUpsTests/SyncUpsListTests.swift index 1baa620..82531da 100644 --- a/SyncUps/SyncUpsTests/SyncUpsListTests.swift +++ b/SyncUps/SyncUpsTests/SyncUpsListTests.swift @@ -1,6 +1,7 @@ import CasePaths import CustomDump import Dependencies +import DependenciesTestSupport import Foundation import IdentifiedCollections import Testing @@ -10,22 +11,16 @@ import Testing @MainActor @Suite struct SyncUpsListTests { - @Test + @Test( + .dependency(\.continuousClock, ImmediateClock()), + .dependency(\.uuid, .incrementing) + ) func add() async throws { - let savedData = LockIsolated(Data?.none) - - let model = withDependencies { - $0.continuousClock = ImmediateClock() - $0.dataManager = .mock() - $0.dataManager.save = { @Sendable data, _ in savedData.setValue(data) } - $0.uuid = .incrementing - } operation: { - SyncUpsListModel() - } + let model = SyncUpsListModel() model.addSyncUpButtonTapped() - let addModel = try #require(model.destination?.add) + let addModel = try #require(model.addSyncUp) addModel.syncUp.title = "Engineering" addModel.syncUp.attendees[0].name = "Blob" @@ -33,7 +28,7 @@ struct SyncUpsListTests { addModel.syncUp.attendees[1].name = "Blob Jr." model.confirmAddSyncUpButtonTapped() - #expect(model.destination == nil) + #expect(model.addSyncUp == nil) expectNoDifference( model.syncUps, @@ -56,32 +51,27 @@ struct SyncUpsListTests { ) } - @Test + @Test( + .dependency(\.continuousClock, ImmediateClock()), + .dependency(\.uuid, .incrementing) + ) func addValidatedAttendees() async throws { - let model = withDependencies { - $0.continuousClock = ImmediateClock() - $0.dataManager = .mock() - $0.uuid = .incrementing - } operation: { - SyncUpsListModel( - destination: .add( - SyncUpFormModel( - syncUp: SyncUp( - id: SyncUp.ID(uuidString: "deadbeef-dead-beef-dead-beefdeadbeef")!, - attendees: [ - Attendee(id: Attendee.ID(), name: ""), - Attendee(id: Attendee.ID(), name: " "), - ], - title: "Design" - ) - ) + let model = SyncUpsListModel( + addSyncUp: SyncUpFormModel( + syncUp: SyncUp( + id: SyncUp.ID(uuidString: "deadbeef-dead-beef-dead-beefdeadbeef")!, + attendees: [ + Attendee(id: Attendee.ID(), name: ""), + Attendee(id: Attendee.ID(), name: " "), + ], + title: "Design" ) ) - } + ) model.confirmAddSyncUpButtonTapped() - #expect(model.destination == nil) + #expect(model.addSyncUp == nil) expectNoDifference( model.syncUps, [ @@ -98,67 +88,4 @@ struct SyncUpsListTests { ] ) } - - @Test - func loadingDataDecodingFailed() async throws { - let model = withDependencies { - $0.continuousClock = ImmediateClock() - $0.dataManager = .mock( - initialData: Data("!@#$ BAD DATA %^&*()".utf8) - ) - } operation: { - SyncUpsListModel() - } - - let alert = try #require(model.destination?.alert) - - expectNoDifference(alert, .dataFailedToLoad) - - model.alertButtonTapped(.confirmLoadMockData) - - expectNoDifference(model.syncUps, [.mock, .designMock, .engineeringMock]) - } - - @Test - func loadingDataFileNotFound() async throws { - let model = withDependencies { - $0.dataManager.load = { @Sendable _ in - struct FileNotFound: Error {} - throw FileNotFound() - } - } operation: { - SyncUpsListModel() - } - - #expect(model.destination == nil) - } - - @Test - func save() async throws { - let clock = TestClock() - - let savedData = LockIsolated(Data()) - await confirmation { confirmation in - let model = withDependencies { - $0.dataManager.load = { @Sendable _ in try JSONEncoder().encode([SyncUp]()) } - $0.dataManager.save = { @Sendable data, _ in - savedData.setValue(data) - confirmation() - } - $0.continuousClock = clock - } operation: { - SyncUpsListModel( - destination: .add(SyncUpFormModel(syncUp: .mock)) - ) - } - - model.confirmAddSyncUpButtonTapped() - await clock.advance(by: .seconds(1)) - } - - expectNoDifference( - try JSONDecoder().decode([SyncUp].self, from: savedData.value), - [.mock] - ) - } } diff --git a/SyncUps/SyncUpsUITests/SyncUpsListUITests.swift b/SyncUps/SyncUpsUITests/SyncUpsListUITests.swift index f7342f2..cc2181f 100644 --- a/SyncUps/SyncUpsUITests/SyncUpsListUITests.swift +++ b/SyncUps/SyncUpsUITests/SyncUpsListUITests.swift @@ -34,104 +34,89 @@ final class SyncUpsListUITests: XCTestCase { // available on next launch. @MainActor func testAdd() throws { - self.app.navigationBars["Daily Sync-ups"].buttons["Add"].tap() - let titleTextField = self.app.collectionViews.textFields["Title"] - let nameTextField = self.app.collectionViews.textFields["Name"] + app.navigationBars["Daily Sync-ups"].buttons["Add"].tap() + let titleTextField = app.collectionViews.textFields["Title"] + let nameTextField = app.collectionViews.textFields["Name"] titleTextField.typeText("Engineering") nameTextField.tap() nameTextField.typeText("Blob") - self.app.buttons["New attendee"].tap() - self.app.typeText("Blob Jr.") + app.buttons["New attendee"].tap() + app.typeText("Blob Jr.") - self.app.navigationBars["New sync-up"].buttons["Add"].tap() + app.navigationBars["New sync-up"].buttons["Add"].tap() - XCTAssertEqual(self.app.staticTexts["Engineering"].exists, true) + XCTAssertEqual(app.staticTexts["Engineering"].exists, true) } @MainActor func testDelete() async throws { - self.app.staticTexts["Design"].tap() + app.staticTexts["Design"].tap() - self.app.buttons["Delete"].tap() - XCTAssertEqual(self.app.staticTexts["Delete?"].exists, true) + app.buttons["Delete"].tap() + XCTAssertEqual(app.staticTexts["Delete?"].exists, true) - self.app.buttons["Yes"].tap() - try await Task.sleep(for: .seconds(0.3)) - XCTAssertEqual(self.app.staticTexts["Design"].exists, false) - XCTAssertEqual(self.app.staticTexts["Daily Sync-ups"].exists, true) + app.buttons["Yes"].tap() + try await Task.sleep(for: .seconds(0.5)) + XCTAssertEqual(app.staticTexts["Design"].exists, false) + XCTAssertEqual(app.staticTexts["Daily Sync-ups"].exists, true) } @MainActor func testEdit() async throws { - self.app.staticTexts["Design"].tap() + app.staticTexts["Design"].tap() - self.app.buttons["Edit"].tap() - let titleTextField = self.app.textFields["Title"] + app.buttons["Edit"].tap() + let titleTextField = app.textFields["Title"] titleTextField.typeText(" & Product") - self.app.buttons["Done"].tap() - XCTAssertEqual(self.app.staticTexts["Design & Product"].exists, true) + app.buttons["Done"].tap() + XCTAssertEqual(app.staticTexts["Design & Product"].exists, true) - self.app.buttons["Daily Sync-ups"].tap() + app.buttons["Daily Sync-ups"].tap() try await Task.sleep(for: .seconds(0.3)) - XCTAssertEqual(self.app.staticTexts["Design & Product"].exists, true) - XCTAssertEqual(self.app.staticTexts["Daily Sync-ups"].exists, true) + XCTAssertEqual(app.staticTexts["Design & Product"].exists, true) + XCTAssertEqual(app.staticTexts["Daily Sync-ups"].exists, true) } @MainActor func testRecord() async throws { - self.app.staticTexts["Design"].tap() + app.staticTexts["Design"].tap() - self.app.buttons["Start Meeting"].tap() - self.app.buttons["End meeting"].tap() + app.buttons["Start Meeting"].tap() + app.buttons["End meeting"].tap() - XCTAssertEqual(self.app.staticTexts["End meeting?"].exists, true) - self.app.buttons["Save and end"].tap() + XCTAssertEqual(app.staticTexts["End meeting?"].exists, true) + app.buttons["Save and end"].tap() try await Task.sleep(for: .seconds(0.5)) - XCTAssertEqual(self.app.staticTexts["Design"].exists, true) - XCTAssertEqual(self.app.staticTexts["February 13, 2009"].exists, true) + XCTAssertEqual(app.staticTexts["Design"].exists, true) + XCTAssertEqual(app.staticTexts["February 13, 2009"].exists, true) - self.app.buttons["Daily Sync-ups"].tap() - self.app.staticTexts["Design"].tap() + app.buttons["Daily Sync-ups"].tap() + app.staticTexts["Design"].tap() - XCTAssertEqual(self.app.staticTexts["Design"].exists, true) - XCTAssertEqual(self.app.staticTexts["February 13, 2009"].exists, true) + XCTAssertEqual(app.staticTexts["Design"].exists, true) + XCTAssertEqual(app.staticTexts["February 13, 2009"].exists, true) - self.app.staticTexts["February 13, 2009"].tap() - self.app.staticTexts["Hello world!"].tap() + app.staticTexts["February 13, 2009"].tap() + app.staticTexts["Hello world!"].tap() } @MainActor func testRecord_Discard() async throws { - self.app.staticTexts["Design"].tap() + app.staticTexts["Design"].tap() - self.app.buttons["Start Meeting"].tap() - self.app.buttons["End meeting"].tap() + app.buttons["Start Meeting"].tap() + app.buttons["End meeting"].tap() - XCTAssertEqual(self.app.staticTexts["End meeting?"].exists, true) - self.app.buttons["Discard"].tap() + XCTAssertEqual(app.staticTexts["End meeting?"].exists, true) + app.buttons["Discard"].tap() try await Task.sleep(for: .seconds(0.5)) - XCTAssertEqual(self.app.staticTexts["Design"].exists, true) - XCTAssertEqual(self.app.staticTexts["February 13, 2009"].exists, false) - } - - @MainActor - func testPersistence() async throws { - XCTAssertEqual(self.app.staticTexts["Engineering"].exists, false) - - self.app.navigationBars["Daily Sync-ups"].buttons["Add"].tap() - let titleTextField = self.app.collectionViews.textFields["Title"] - titleTextField.typeText("Engineering") - self.app.navigationBars["New sync-up"].buttons["Add"].tap() - try await Task.sleep(for: .seconds(1)) - - XCUIDevice.shared.press(.home) - self.app.launch() - XCTAssertEqual(self.app.staticTexts["Engineering"].exists, true) + XCTAssertEqual(app.staticTexts["Design"].exists, true) + XCTAssertEqual(app.staticTexts["February 13, 2009"].exists, false) } }