diff --git a/CHANGELOG.md b/CHANGELOG.md index bede444..c88cb6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,9 @@ **v0.3.0 - Tyranus**: - Feedback: introduce the "on:" keyword to explicitly declare the type of state that concerns the side effect +- Feedback: replace the parameter "sideEffect" by "perform" to have a nice readable sentence: ...(on: Loading.self, ..., perform: sideEffect) +- State Machine: introduce a new DSL based on From/On that allows to group transitions from the same state type +- State Machine: provide assert functions to ease the unit tests of transitions **v0.2.0 - Vader**: diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index 8fa1044..c0891f3 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -7,11 +7,13 @@ objects = { /* Begin PBXBuildFile section */ + 741C474025EC5CDB00F1231B /* Counter+TransitionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 741C473F25EC5CDB00F1231B /* Counter+TransitionsTests.swift */; }; + 741C475C25EC5DE100F1231B /* Feedbacks in Frameworks */ = {isa = PBXBuildFile; productRef = 741C475B25EC5DE100F1231B /* Feedbacks */; }; + 741C476625EC5E4C00F1231B /* FeedbacksTest in Frameworks */ = {isa = PBXBuildFile; productRef = 741C476525EC5E4C00F1231B /* FeedbacksTest */; }; 742FEE2425B388DA00575CB2 /* GifList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 742FEE2325B388DA00575CB2 /* GifList.swift */; }; 742FEE2825B38B1A00575CB2 /* GifList+States.swift in Sources */ = {isa = PBXBuildFile; fileRef = 742FEE2725B38B1A00575CB2 /* GifList+States.swift */; }; 742FEE2E25B38EEE00575CB2 /* GifOverview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 742FEE2D25B38EEE00575CB2 /* GifOverview.swift */; }; 742FEE3125B38F5300575CB2 /* GifList+Events.swift in Sources */ = {isa = PBXBuildFile; fileRef = 742FEE3025B38F5300575CB2 /* GifList+Events.swift */; }; - 742FEE3525B390C600575CB2 /* GifList+Transitions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 742FEE3425B390C600575CB2 /* GifList+Transitions.swift */; }; 742FEE3925B3978600575CB2 /* GifList+SideEffects.swift in Sources */ = {isa = PBXBuildFile; fileRef = 742FEE3825B3978600575CB2 /* GifList+SideEffects.swift */; }; 742FEE4325B3AA2500575CB2 /* StorageService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 742FEE4225B3AA2500575CB2 /* StorageService.swift */; }; 742FEE4725B3AC3600575CB2 /* HTTPService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 742FEE4625B3AC3600575CB2 /* HTTPService.swift */; }; @@ -28,7 +30,6 @@ 742FEE7325B4F65300575CB2 /* GifDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 742FEE7225B4F65300575CB2 /* GifDetail.swift */; }; 742FEE7725B506E700575CB2 /* GifDetail+States.swift in Sources */ = {isa = PBXBuildFile; fileRef = 742FEE7625B506E700575CB2 /* GifDetail+States.swift */; }; 742FEE7A25B508FC00575CB2 /* GifDetail+Events.swift in Sources */ = {isa = PBXBuildFile; fileRef = 742FEE7925B508FC00575CB2 /* GifDetail+Events.swift */; }; - 742FEE7E25B50A7C00575CB2 /* GifDetail+Transitions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 742FEE7D25B50A7C00575CB2 /* GifDetail+Transitions.swift */; }; 742FEE8225B50A9000575CB2 /* Images.swift in Sources */ = {isa = PBXBuildFile; fileRef = 742FEE8025B50A9000575CB2 /* Images.swift */; }; 742FEE8325B50A9000575CB2 /* Gif.swift in Sources */ = {isa = PBXBuildFile; fileRef = 742FEE8125B50A9000575CB2 /* Gif.swift */; }; 742FEE8725B50CCA00575CB2 /* GifDetail+SideEffects.swift in Sources */ = {isa = PBXBuildFile; fileRef = 742FEE8625B50CCA00575CB2 /* GifDetail+SideEffects.swift */; }; @@ -36,29 +37,42 @@ 742FEE8D25B5260E00575CB2 /* GifDetailResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 742FEE8B25B5260D00575CB2 /* GifDetailResponse.swift */; }; 742FEE9225B526EB00575CB2 /* GifDetail+System.swift in Sources */ = {isa = PBXBuildFile; fileRef = 742FEE9125B526EB00575CB2 /* GifDetail+System.swift */; }; 742FEE9725B52D2D00575CB2 /* GifDetail+RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 742FEE9625B52D2C00575CB2 /* GifDetail+RootView.swift */; }; + 7435459F25EC80F600C17188 /* GifDetail+TransitionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7435459E25EC80F600C17188 /* GifDetail+TransitionsTests.swift */; }; 747182A425AE7B0B0098E83E /* ExamplesApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 747182A325AE7B0B0098E83E /* ExamplesApp.swift */; }; 747182A625AE7B0B0098E83E /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 747182A525AE7B0B0098E83E /* ContentView.swift */; }; 747182A825AE7B0E0098E83E /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 747182A725AE7B0E0098E83E /* Assets.xcassets */; }; 747182AB25AE7B0E0098E83E /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 747182AA25AE7B0E0098E83E /* Preview Assets.xcassets */; }; - 747182B525AE7FB30098E83E /* Feedbacks in Frameworks */ = {isa = PBXBuildFile; productRef = 747182B425AE7FB30098E83E /* Feedbacks */; }; 747182BA25AE80360098E83E /* CounterApp+RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 747182B925AE80360098E83E /* CounterApp+RootView.swift */; }; 747182C225AE80DC0098E83E /* CounterApp+Events.swift in Sources */ = {isa = PBXBuildFile; fileRef = 747182C125AE80DC0098E83E /* CounterApp+Events.swift */; }; 747182C625AE81180098E83E /* CounterApp+States.swift in Sources */ = {isa = PBXBuildFile; fileRef = 747182C525AE81180098E83E /* CounterApp+States.swift */; }; 747182C925AE82640098E83E /* CounterApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 747182C825AE82640098E83E /* CounterApp.swift */; }; - 747182CC25AE830E0098E83E /* CounterApp+Transitions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 747182CB25AE830E0098E83E /* CounterApp+Transitions.swift */; }; 747182CF25AE85B40098E83E /* CounterApp+SideEffects.swift in Sources */ = {isa = PBXBuildFile; fileRef = 747182CE25AE85B40098E83E /* CounterApp+SideEffects.swift */; }; 747182D425AE878D0098E83E /* CounterApp+System.swift in Sources */ = {isa = PBXBuildFile; fileRef = 747182D325AE878D0098E83E /* CounterApp+System.swift */; }; 749D95FC25CF4ED200795E04 /* GifDetail+Dependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = 749D95FB25CF4ED200795E04 /* GifDetail+Dependencies.swift */; }; 749D960025CF50EF00795E04 /* GifList+Dependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = 749D95FF25CF50EF00795E04 /* GifList+Dependencies.swift */; }; + 74E7F5BC25EC692D00482CC6 /* Counter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74E7F5BB25EC692D00482CC6 /* Counter.swift */; }; + 74E7F65A25EC70A800482CC6 /* GifList+TransitionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74E7F65925EC70A800482CC6 /* GifList+TransitionsTests.swift */; }; 74EAB46C25B7BF0A003283E4 /* GifDetail+ViewState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74EAB46B25B7BF0A003283E4 /* GifDetail+ViewState.swift */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + 741C474225EC5CDB00F1231B /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 7471829825AE7B0B0098E83E /* Project object */; + proxyType = 1; + remoteGlobalIDString = 7471829F25AE7B0B0098E83E; + remoteInfo = Examples; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ + 741C473D25EC5CDB00F1231B /* ExamplesTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ExamplesTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 741C473F25EC5CDB00F1231B /* Counter+TransitionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Counter+TransitionsTests.swift"; sourceTree = ""; }; + 741C474125EC5CDB00F1231B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 742FEE2325B388DA00575CB2 /* GifList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifList.swift; sourceTree = ""; }; 742FEE2725B38B1A00575CB2 /* GifList+States.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GifList+States.swift"; sourceTree = ""; }; 742FEE2D25B38EEE00575CB2 /* GifOverview.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GifOverview.swift; sourceTree = ""; }; 742FEE3025B38F5300575CB2 /* GifList+Events.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GifList+Events.swift"; sourceTree = ""; }; - 742FEE3425B390C600575CB2 /* GifList+Transitions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GifList+Transitions.swift"; sourceTree = ""; }; 742FEE3825B3978600575CB2 /* GifList+SideEffects.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GifList+SideEffects.swift"; sourceTree = ""; }; 742FEE4225B3AA2500575CB2 /* StorageService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageService.swift; sourceTree = ""; }; 742FEE4625B3AC3600575CB2 /* HTTPService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPService.swift; sourceTree = ""; }; @@ -75,7 +89,6 @@ 742FEE7225B4F65300575CB2 /* GifDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifDetail.swift; sourceTree = ""; }; 742FEE7625B506E700575CB2 /* GifDetail+States.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GifDetail+States.swift"; sourceTree = ""; }; 742FEE7925B508FC00575CB2 /* GifDetail+Events.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GifDetail+Events.swift"; sourceTree = ""; }; - 742FEE7D25B50A7C00575CB2 /* GifDetail+Transitions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GifDetail+Transitions.swift"; sourceTree = ""; }; 742FEE8025B50A9000575CB2 /* Images.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Images.swift; sourceTree = ""; }; 742FEE8125B50A9000575CB2 /* Gif.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Gif.swift; sourceTree = ""; }; 742FEE8625B50CCA00575CB2 /* GifDetail+SideEffects.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GifDetail+SideEffects.swift"; sourceTree = ""; }; @@ -83,6 +96,7 @@ 742FEE8B25B5260D00575CB2 /* GifDetailResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GifDetailResponse.swift; sourceTree = ""; }; 742FEE9125B526EB00575CB2 /* GifDetail+System.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GifDetail+System.swift"; sourceTree = ""; }; 742FEE9625B52D2C00575CB2 /* GifDetail+RootView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "GifDetail+RootView.swift"; sourceTree = ""; }; + 7435459E25EC80F600C17188 /* GifDetail+TransitionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GifDetail+TransitionsTests.swift"; sourceTree = ""; }; 747182A025AE7B0B0098E83E /* Examples.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Examples.app; sourceTree = BUILT_PRODUCTS_DIR; }; 747182A325AE7B0B0098E83E /* ExamplesApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExamplesApp.swift; sourceTree = ""; }; 747182A525AE7B0B0098E83E /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; @@ -93,26 +107,60 @@ 747182C125AE80DC0098E83E /* CounterApp+Events.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CounterApp+Events.swift"; sourceTree = ""; }; 747182C525AE81180098E83E /* CounterApp+States.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CounterApp+States.swift"; sourceTree = ""; }; 747182C825AE82640098E83E /* CounterApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CounterApp.swift; sourceTree = ""; }; - 747182CB25AE830E0098E83E /* CounterApp+Transitions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CounterApp+Transitions.swift"; sourceTree = ""; }; 747182CE25AE85B40098E83E /* CounterApp+SideEffects.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CounterApp+SideEffects.swift"; sourceTree = ""; }; 747182D325AE878D0098E83E /* CounterApp+System.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CounterApp+System.swift"; sourceTree = ""; }; 749D95FB25CF4ED200795E04 /* GifDetail+Dependencies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GifDetail+Dependencies.swift"; sourceTree = ""; }; 749D95FF25CF50EF00795E04 /* GifList+Dependencies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GifList+Dependencies.swift"; sourceTree = ""; }; + 74E7F5BB25EC692D00482CC6 /* Counter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Counter.swift; sourceTree = ""; }; + 74E7F65925EC70A800482CC6 /* GifList+TransitionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GifList+TransitionsTests.swift"; sourceTree = ""; }; 74EAB46B25B7BF0A003283E4 /* GifDetail+ViewState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GifDetail+ViewState.swift"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 741C473A25EC5CDB00F1231B /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 741C476625EC5E4C00F1231B /* FeedbacksTest in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 7471829D25AE7B0B0098E83E /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 747182B525AE7FB30098E83E /* Feedbacks in Frameworks */, + 741C475C25EC5DE100F1231B /* Feedbacks in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 741C473E25EC5CDB00F1231B /* ExamplesTests */ = { + isa = PBXGroup; + children = ( + 741C476B25EC66C500F1231B /* CounterApp */, + 74E7F65825EC708300482CC6 /* GiphyApp */, + 741C474125EC5CDB00F1231B /* Info.plist */, + ); + path = ExamplesTests; + sourceTree = ""; + }; + 741C474925EC5D2400F1231B /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; + 741C476B25EC66C500F1231B /* CounterApp */ = { + isa = PBXGroup; + children = ( + 741C473F25EC5CDB00F1231B /* Counter+TransitionsTests.swift */, + ); + path = CounterApp; + sourceTree = ""; + }; 742FEE1E25B3875800575CB2 /* GiphyApp */ = { isa = PBXGroup; children = ( @@ -152,7 +200,6 @@ 742FEE3825B3978600575CB2 /* GifList+SideEffects.swift */, 742FEE2725B38B1A00575CB2 /* GifList+States.swift */, 742FEE4A25B3B3A200575CB2 /* GifList+System.swift */, - 742FEE3425B390C600575CB2 /* GifList+Transitions.swift */, ); path = System; sourceTree = ""; @@ -234,7 +281,6 @@ 742FEE8625B50CCA00575CB2 /* GifDetail+SideEffects.swift */, 742FEE7625B506E700575CB2 /* GifDetail+States.swift */, 742FEE9125B526EB00575CB2 /* GifDetail+System.swift */, - 742FEE7D25B50A7C00575CB2 /* GifDetail+Transitions.swift */, ); path = System; sourceTree = ""; @@ -252,7 +298,9 @@ isa = PBXGroup; children = ( 747182A225AE7B0B0098E83E /* Examples */, + 741C473E25EC5CDB00F1231B /* ExamplesTests */, 747182A125AE7B0B0098E83E /* Products */, + 741C474925EC5D2400F1231B /* Frameworks */, ); sourceTree = ""; }; @@ -260,6 +308,7 @@ isa = PBXGroup; children = ( 747182A025AE7B0B0098E83E /* Examples.app */, + 741C473D25EC5CDB00F1231B /* ExamplesTests.xctest */, ); name = Products; sourceTree = ""; @@ -289,6 +338,7 @@ 747182B725AE7FCF0098E83E /* CounterApp */ = { isa = PBXGroup; children = ( + 74E7F5BA25EC691200482CC6 /* Models */, 747182BD25AE80950098E83E /* System */, 747182BE25AE809E0098E83E /* Views */, 747182C825AE82640098E83E /* CounterApp.swift */, @@ -303,7 +353,6 @@ 747182CE25AE85B40098E83E /* CounterApp+SideEffects.swift */, 747182C525AE81180098E83E /* CounterApp+States.swift */, 747182D325AE878D0098E83E /* CounterApp+System.swift */, - 747182CB25AE830E0098E83E /* CounterApp+Transitions.swift */, ); path = System; sourceTree = ""; @@ -316,9 +365,47 @@ path = Views; sourceTree = ""; }; + 74E7F5BA25EC691200482CC6 /* Models */ = { + isa = PBXGroup; + children = ( + 74E7F5BB25EC692D00482CC6 /* Counter.swift */, + ); + path = Models; + sourceTree = ""; + }; + 74E7F65825EC708300482CC6 /* GiphyApp */ = { + isa = PBXGroup; + children = ( + 7435459E25EC80F600C17188 /* GifDetail+TransitionsTests.swift */, + 74E7F65925EC70A800482CC6 /* GifList+TransitionsTests.swift */, + ); + path = GiphyApp; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 741C473C25EC5CDB00F1231B /* ExamplesTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 741C474425EC5CDB00F1231B /* Build configuration list for PBXNativeTarget "ExamplesTests" */; + buildPhases = ( + 741C473925EC5CDB00F1231B /* Sources */, + 741C473A25EC5CDB00F1231B /* Frameworks */, + 741C473B25EC5CDB00F1231B /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 741C474325EC5CDB00F1231B /* PBXTargetDependency */, + ); + name = ExamplesTests; + packageProductDependencies = ( + 741C476525EC5E4C00F1231B /* FeedbacksTest */, + ); + productName = ExamplesTests; + productReference = 741C473D25EC5CDB00F1231B /* ExamplesTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; 7471829F25AE7B0B0098E83E /* Examples */ = { isa = PBXNativeTarget; buildConfigurationList = 747182AF25AE7B0E0098E83E /* Build configuration list for PBXNativeTarget "Examples" */; @@ -333,7 +420,7 @@ ); name = Examples; packageProductDependencies = ( - 747182B425AE7FB30098E83E /* Feedbacks */, + 741C475B25EC5DE100F1231B /* Feedbacks */, ); productName = Examples; productReference = 747182A025AE7B0B0098E83E /* Examples.app */; @@ -345,9 +432,13 @@ 7471829825AE7B0B0098E83E /* Project object */ = { isa = PBXProject; attributes = { - LastSwiftUpdateCheck = 1230; + LastSwiftUpdateCheck = 1240; LastUpgradeCheck = 1230; TargetAttributes = { + 741C473C25EC5CDB00F1231B = { + CreatedOnToolsVersion = 12.4; + TestTargetID = 7471829F25AE7B0B0098E83E; + }; 7471829F25AE7B0B0098E83E = { CreatedOnToolsVersion = 12.3; }; @@ -363,18 +454,26 @@ ); mainGroup = 7471829725AE7B0B0098E83E; packageReferences = ( - 747182B325AE7FB30098E83E /* XCRemoteSwiftPackageReference "Feedbacks" */, + 741C475A25EC5DE100F1231B /* XCRemoteSwiftPackageReference "Feedbacks" */, ); productRefGroup = 747182A125AE7B0B0098E83E /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( 7471829F25AE7B0B0098E83E /* Examples */, + 741C473C25EC5CDB00F1231B /* ExamplesTests */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 741C473B25EC5CDB00F1231B /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 7471829E25AE7B0B0098E83E /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -387,10 +486,21 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 741C473925EC5CDB00F1231B /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 7435459F25EC80F600C17188 /* GifDetail+TransitionsTests.swift in Sources */, + 74E7F65A25EC70A800482CC6 /* GifList+TransitionsTests.swift in Sources */, + 741C474025EC5CDB00F1231B /* Counter+TransitionsTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 7471829C25AE7B0B0098E83E /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 74E7F5BC25EC692D00482CC6 /* Counter.swift in Sources */, 747182C925AE82640098E83E /* CounterApp.swift in Sources */, 742FEE3125B38F5300575CB2 /* GifList+Events.swift in Sources */, 747182C625AE81180098E83E /* CounterApp+States.swift in Sources */, @@ -400,7 +510,6 @@ 742FEE4725B3AC3600575CB2 /* HTTPService.swift in Sources */, 742FEE2825B38B1A00575CB2 /* GifList+States.swift in Sources */, 742FEE5725B3CB6000575CB2 /* Pagination.swift in Sources */, - 742FEE7E25B50A7C00575CB2 /* GifDetail+Transitions.swift in Sources */, 749D95FC25CF4ED200795E04 /* GifDetail+Dependencies.swift in Sources */, 742FEE6325B3CED500575CB2 /* GifList+RowView.swift in Sources */, 742FEE3925B3978600575CB2 /* GifList+SideEffects.swift in Sources */, @@ -415,7 +524,6 @@ 742FEE7A25B508FC00575CB2 /* GifDetail+Events.swift in Sources */, 742FEE5C25B3CC7200575CB2 /* GifList+RootView.swift in Sources */, 742FEE5025B3B42D00575CB2 /* GifListRequestParameter.swift in Sources */, - 747182CC25AE830E0098E83E /* CounterApp+Transitions.swift in Sources */, 742FEE6C25B3D58300575CB2 /* Encodable+Dictionary.swift in Sources */, 742FEE7325B4F65300575CB2 /* GifDetail.swift in Sources */, 742FEE8D25B5260E00575CB2 /* GifDetailResponse.swift in Sources */, @@ -430,14 +538,65 @@ 742FEE8325B50A9000575CB2 /* Gif.swift in Sources */, 742FEE4B25B3B3A200575CB2 /* GifList+System.swift in Sources */, 742FEE5125B3B42D00575CB2 /* GifListResponse.swift in Sources */, - 742FEE3525B390C600575CB2 /* GifList+Transitions.swift in Sources */, 747182A425AE7B0B0098E83E /* ExamplesApp.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 741C474325EC5CDB00F1231B /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 7471829F25AE7B0B0098E83E /* Examples */; + targetProxy = 741C474225EC5CDB00F1231B /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ + 741C474525EC5CDB00F1231B /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 3V5265LQM9; + DISABLE_DIAMOND_PROBLEM_DIAGNOSTIC = YES; + INFOPLIST_FILE = ExamplesTests/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 14.4; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = io.wapfactor.Examples.ExamplesTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Examples.app/Examples"; + }; + name = Debug; + }; + 741C474625EC5CDB00F1231B /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 3V5265LQM9; + DISABLE_DIAMOND_PROBLEM_DIAGNOSTIC = YES; + INFOPLIST_FILE = ExamplesTests/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 14.4; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = io.wapfactor.Examples.ExamplesTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Examples.app/Examples"; + }; + name = Release; + }; 747182AD25AE7B0E0098E83E /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -601,6 +760,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 741C474425EC5CDB00F1231B /* Build configuration list for PBXNativeTarget "ExamplesTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 741C474525EC5CDB00F1231B /* Debug */, + 741C474625EC5CDB00F1231B /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 7471829B25AE7B0B0098E83E /* Build configuration list for PBXProject "Examples" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -622,22 +790,27 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ - 747182B325AE7FB30098E83E /* XCRemoteSwiftPackageReference "Feedbacks" */ = { + 741C475A25EC5DE100F1231B /* XCRemoteSwiftPackageReference "Feedbacks" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "git@github.com:twittemb/Feedbacks.git"; + repositoryURL = "git@github.com:CombineCommunity/Feedbacks.git"; requirement = { kind = revision; - revision = ceca7e90a065cbb67346d0234243f598500ddfc5; + revision = 826c7798699acd5a9d0348049eaeb8917399a94f; }; }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - 747182B425AE7FB30098E83E /* Feedbacks */ = { + 741C475B25EC5DE100F1231B /* Feedbacks */ = { isa = XCSwiftPackageProductDependency; - package = 747182B325AE7FB30098E83E /* XCRemoteSwiftPackageReference "Feedbacks" */; + package = 741C475A25EC5DE100F1231B /* XCRemoteSwiftPackageReference "Feedbacks" */; productName = Feedbacks; }; + 741C476525EC5E4C00F1231B /* FeedbacksTest */ = { + isa = XCSwiftPackageProductDependency; + package = 741C475A25EC5DE100F1231B /* XCRemoteSwiftPackageReference "Feedbacks" */; + productName = FeedbacksTest; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 7471829825AE7B0B0098E83E /* Project object */; diff --git a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index be939f9..12b3ed3 100644 --- a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -3,10 +3,10 @@ "pins": [ { "package": "Feedbacks", - "repositoryURL": "git@github.com:twittemb/Feedbacks.git", + "repositoryURL": "git@github.com:CombineCommunity/Feedbacks.git", "state": { "branch": null, - "revision": "ceca7e90a065cbb67346d0234243f598500ddfc5", + "revision": "826c7798699acd5a9d0348049eaeb8917399a94f", "version": null } } diff --git a/Examples/Examples/ContentView.swift b/Examples/Examples/ContentView.swift index cbb1c15..7987f6f 100644 --- a/Examples/Examples/ContentView.swift +++ b/Examples/Examples/ContentView.swift @@ -24,7 +24,7 @@ struct ContentView: View { destination: GifList.RootView( system: GifList .System - .gifOverview + .gifs .uiSystem(viewStateFactory: GifList.ViewState.stateToViewState(state:)) .run() )) diff --git a/Examples/Examples/CounterApp/Models/Counter.swift b/Examples/Examples/CounterApp/Models/Counter.swift new file mode 100644 index 0000000..d660f1b --- /dev/null +++ b/Examples/Examples/CounterApp/Models/Counter.swift @@ -0,0 +1,20 @@ +// +// Counter.swift +// Examples +// +// Created by Thibault Wittemberg on 2021-02-28. +// + +struct Counter: Equatable { + let value: Int + let min: Int + let max: Int + + func decrease() -> Counter { + Counter(value: self.value - 1, min: self.min, max: self.max) + } + + func increase() -> Counter { + Counter(value: self.value + 1, min: self.min, max: self.max) + } +} diff --git a/Examples/Examples/CounterApp/System/CounterApp+Events.swift b/Examples/Examples/CounterApp/System/CounterApp+Events.swift index 1c7c592..0a3c7ca 100644 --- a/Examples/Examples/CounterApp/System/CounterApp+Events.swift +++ b/Examples/Examples/CounterApp/System/CounterApp+Events.swift @@ -14,13 +14,8 @@ extension CounterApp { extension CounterApp.Events { struct TogglePause: Event {} - - struct Reset: Event { - let value: Int - } - + struct Reset: Event {} struct Increase: Event {} - struct Decrease: Event {} } diff --git a/Examples/Examples/CounterApp/System/CounterApp+SideEffects.swift b/Examples/Examples/CounterApp/System/CounterApp+SideEffects.swift index c1f0dd6..974073d 100644 --- a/Examples/Examples/CounterApp/System/CounterApp+SideEffects.swift +++ b/Examples/Examples/CounterApp/System/CounterApp+SideEffects.swift @@ -16,33 +16,17 @@ extension CounterApp { extension CounterApp.SideEffects { // This effect will make the state decrease when it is already decreasing and not paused - // When the state is equal to 0, then the effect asks for an increase static func decreaseEffect(state: CounterApp.States.Decreasing) -> AnyPublisher { guard !state.isPaused else { return Empty().eraseToAnyPublisher() } - - if state.value > 0 { - return Just(CounterApp.Events.Decrease()) - .delay(for: 1, scheduler: DispatchQueue(label: UUID().uuidString)) - .eraseToAnyPublisher() - } - - return Just(CounterApp.Events.Increase()) + return Just(CounterApp.Events.Decrease()) .delay(for: 1, scheduler: DispatchQueue(label: UUID().uuidString)) .eraseToAnyPublisher() } // This effect will make the state increase when it is already increasing and not paused - // When the state is equal to 10, then the effect asks for a decrease static func increaseEffect(state: CounterApp.States.Increasing) -> AnyPublisher { guard !state.isPaused else { return Empty().eraseToAnyPublisher() } - - if state.value < 10 { - return Just(CounterApp.Events.Increase()) - .delay(for: 1, scheduler: DispatchQueue(label: UUID().uuidString)) - .eraseToAnyPublisher() - } - - return Just(CounterApp.Events.Decrease()) + return Just(CounterApp.Events.Increase()) .delay(for: 1, scheduler: DispatchQueue(label: UUID().uuidString)) .eraseToAnyPublisher() } diff --git a/Examples/Examples/CounterApp/System/CounterApp+States.swift b/Examples/Examples/CounterApp/System/CounterApp+States.swift index faab3c1..ba257e1 100644 --- a/Examples/Examples/CounterApp/System/CounterApp+States.swift +++ b/Examples/Examples/CounterApp/System/CounterApp+States.swift @@ -13,17 +13,17 @@ extension CounterApp { } extension CounterApp.States { - struct Fixed: State { - let value: Int + struct Fixed: State, Equatable { + let counter: Counter } - struct Increasing: State { - let value: Int + struct Increasing: State, Equatable { + let counter: Counter let isPaused: Bool } - struct Decreasing: State { - let value: Int + struct Decreasing: State, Equatable { + let counter: Counter let isPaused: Bool } } diff --git a/Examples/Examples/CounterApp/System/CounterApp+System.swift b/Examples/Examples/CounterApp/System/CounterApp+System.swift index 36b82ca..1a06392 100644 --- a/Examples/Examples/CounterApp/System/CounterApp+System.swift +++ b/Examples/Examples/CounterApp/System/CounterApp+System.swift @@ -15,17 +15,17 @@ extension CounterApp { extension CounterApp.System { static let counter = System { InitialState { - CounterApp.States.Fixed(value: 10) + CounterApp.States.Fixed(counter: Counter(value: 10, min: 0, max: 10)) } Feedbacks { Feedback(on: CounterApp.States.Decreasing.self, strategy: .cancelOnNewState, - sideEffect: CounterApp.SideEffects.decreaseEffect(state:)) + perform: CounterApp.SideEffects.decreaseEffect(state:)) Feedback(on: CounterApp.States.Increasing.self, strategy: .cancelOnNewState, - sideEffect: CounterApp.SideEffects.increaseEffect(state:)) + perform: CounterApp.SideEffects.increaseEffect(state:)) } .onStateReceived { print("Counter: New state has been received: \($0)") @@ -35,10 +35,37 @@ extension CounterApp.System { } Transitions { - CounterApp.Transitions.fixedTransition - CounterApp.Transitions.resetTransition - CounterApp.Transitions.decreasingTransitions - CounterApp.Transitions.increasingTransitions + From(CounterApp.States.Fixed.self) { state in + On(CounterApp.Events.TogglePause.self, transitionTo: CounterApp.States.Decreasing(counter: state.counter, isPaused: false)) + } + + From(AnyState.self) { + On(CounterApp.Events.Reset.self) { + CounterApp.States.Fixed(counter: Counter(value: 10, min: 0, max: 10)) + } + } + + From(CounterApp.States.Decreasing.self) { state in + On(CounterApp.Events.TogglePause.self, transitionTo: CounterApp.States.Decreasing(counter: state.counter, isPaused: !state.isPaused)) + On(CounterApp.Events.Decrease.self) { + guard !state.isPaused else { return state } + if state.counter.value == state.counter.min { + return CounterApp.States.Increasing(counter: state.counter.increase(), isPaused: false) + } + return CounterApp.States.Decreasing(counter: state.counter.decrease(), isPaused: false) + } + } + + From(CounterApp.States.Increasing.self) { state in + On(CounterApp.Events.TogglePause.self, transitionTo: CounterApp.States.Increasing(counter: state.counter, isPaused: !state.isPaused)) + On(CounterApp.Events.Increase.self) { + guard !state.isPaused else { return state } + if state.counter.value == state.counter.max { + return CounterApp.States.Decreasing(counter: state.counter.decrease(), isPaused: false) + } + return CounterApp.States.Increasing(counter: state.counter.increase(), isPaused: false) + } + } } } } diff --git a/Examples/Examples/CounterApp/System/CounterApp+Transitions.swift b/Examples/Examples/CounterApp/System/CounterApp+Transitions.swift deleted file mode 100644 index 09923b4..0000000 --- a/Examples/Examples/CounterApp/System/CounterApp+Transitions.swift +++ /dev/null @@ -1,56 +0,0 @@ -// -// CounterApp+Transitions.swift -// Examples -// -// Created by Thibault Wittemberg on 2021-01-12. -// - -import Feedbacks - -// define a namespace for this app's transitions -extension CounterApp { - enum Transitions {} -} - -extension CounterApp.Transitions { - static let fixedTransition = Transition(from: CounterApp.States.Fixed.self, - on: CounterApp.Events.TogglePause.self) { state, _ -> State in - CounterApp.States.Decreasing(value: state.value, isPaused: false) - } - - static let resetTransition = Transition(from: AnyState.self, on: CounterApp.Events.Reset.self) { _, event -> State in - CounterApp.States.Fixed(value: event.value) - } - - static let decreasingTransitions = Transitions { - Transition(from: CounterApp.States.Decreasing.self, on: CounterApp.Events.TogglePause.self) { state, _ -> State in - CounterApp.States.Decreasing(value: state.value, isPaused: !state.isPaused) - } - - Transition(from: CounterApp.States.Decreasing.self, on: CounterApp.Events.Decrease.self) { state, _ -> State in - guard !state.isPaused else { return state } - return CounterApp.States.Decreasing(value: state.value - 1, isPaused: false) - } - - Transition(from: CounterApp.States.Decreasing.self, on: CounterApp.Events.Increase.self) { state, _ -> State in - guard !state.isPaused else { return state } - return CounterApp.States.Increasing(value: state.value + 1, isPaused: false) - } - } - - static let increasingTransitions = Transitions { - Transition(from: CounterApp.States.Increasing.self, on: CounterApp.Events.TogglePause.self) { state, _ -> State in - CounterApp.States.Increasing(value: state.value, isPaused: !state.isPaused) - } - - Transition(from: CounterApp.States.Increasing.self, on: CounterApp.Events.Decrease.self) { state, _ -> State in - guard !state.isPaused else { return state } - return CounterApp.States.Decreasing(value: state.value - 1, isPaused: false) - } - - Transition(from: CounterApp.States.Increasing.self, on: CounterApp.Events.Increase.self) { state, _ -> State in - guard !state.isPaused else { return state } - return CounterApp.States.Increasing(value: state.value + 1, isPaused: false) - } - } -} diff --git a/Examples/Examples/CounterApp/Views/CounterApp+RootView.swift b/Examples/Examples/CounterApp/Views/CounterApp+RootView.swift index e4a6c98..0047454 100644 --- a/Examples/Examples/CounterApp/Views/CounterApp+RootView.swift +++ b/Examples/Examples/CounterApp/Views/CounterApp+RootView.swift @@ -29,7 +29,7 @@ extension CounterApp { HStack { Spacer() Button(action: { - self.system.emit(CounterApp.Events.Reset(value: 10)) + self.system.emit(CounterApp.Events.Reset()) }) { Text("Reset") .font(.system(size: 25)) @@ -66,9 +66,9 @@ extension CounterApp { private func counterValue(from rawState: RawState) -> Int { switch rawState.state { - case let fixed as CounterApp.States.Fixed: return fixed.value - case let decreasing as CounterApp.States.Decreasing: return decreasing.value - case let increasing as CounterApp.States.Increasing: return increasing.value + case let fixed as CounterApp.States.Fixed: return fixed.counter.value + case let decreasing as CounterApp.States.Decreasing: return decreasing.counter.value + case let increasing as CounterApp.States.Increasing: return increasing.counter.value default: return 0 } } @@ -99,11 +99,11 @@ extension CounterApp { private func counterDescription(from rawState: RawState) -> String { switch rawState.state { case let fixed as CounterApp.States.Fixed: - return "Fixed(value: \(fixed.value))" + return "Fixed(value: \(fixed.counter.value))" case let decreasing as CounterApp.States.Decreasing: - return "Decreasing(value: \(decreasing.value), paused: \(decreasing.isPaused))" + return "Decreasing(value: \(decreasing.counter.value), paused: \(decreasing.isPaused))" case let increasing as CounterApp.States.Increasing: - return "Increasing(value: \(increasing.value), paused: \(increasing.isPaused))" + return "Increasing(value: \(increasing.counter.value), paused: \(increasing.isPaused))" default: return "undefined" } } diff --git a/Examples/Examples/GiphyApp/Features/GifDetail/System/GifDetail+States.swift b/Examples/Examples/GiphyApp/Features/GifDetail/System/GifDetail+States.swift index 41c7efd..3e8e6f8 100644 --- a/Examples/Examples/GiphyApp/Features/GifDetail/System/GifDetail+States.swift +++ b/Examples/Examples/GiphyApp/Features/GifDetail/System/GifDetail+States.swift @@ -12,17 +12,17 @@ extension GifDetail { } extension GifDetail.States { - struct Loading: State {} + struct Loading: State, Equatable {} - struct Loaded: State { + struct Loaded: State, Equatable { let gif: Gif let isFavorite: Bool } - struct TogglingFavorite: State { + struct TogglingFavorite: State, Equatable { let gif: Gif let isFavorite: Bool } - struct Failed: State {} + struct Failed: State, Equatable {} } diff --git a/Examples/Examples/GiphyApp/Features/GifDetail/System/GifDetail+System.swift b/Examples/Examples/GiphyApp/Features/GifDetail/System/GifDetail+System.swift index 9e08755..3628d8e 100644 --- a/Examples/Examples/GiphyApp/Features/GifDetail/System/GifDetail+System.swift +++ b/Examples/Examples/GiphyApp/Features/GifDetail/System/GifDetail+System.swift @@ -31,10 +31,10 @@ extension GifDetail.System { } Feedbacks { - Feedback(on: GifDetail.States.Loading.self, strategy: .cancelOnNewState, sideEffect: loadSideEffect) + Feedback(on: GifDetail.States.Loading.self, strategy: .cancelOnNewState, perform: loadSideEffect) .execute(on: DispatchQueue(label: "Load Gif Queue")) - Feedback(on: GifDetail.States.TogglingFavorite.self, strategy: .cancelOnNewState, sideEffect: toggleFavoriteSideEffect) + Feedback(on: GifDetail.States.TogglingFavorite.self, strategy: .cancelOnNewState, perform: toggleFavoriteSideEffect) .execute(on: DispatchQueue(label: "Toggle Favorite Queue")) } .onStateReceived { @@ -45,9 +45,24 @@ extension GifDetail.System { } Transitions { - GifDetail.Transitions.loadingTransitions - GifDetail.Transitions.loadedTransition - GifDetail.Transitions.togglingTransitions + From(GifDetail.States.Loading.self) { + On(GifDetail.Events.LoadingIsComplete.self) { event in + GifDetail.States.Loaded(gif: event.gif, isFavorite: event.isFavorite) + } + + On(GifDetail.Events.LoadingHasFailed.self, transitionTo: GifDetail.States.Failed()) + } + + From(GifDetail.States.Loaded.self) { state in + On(GifDetail.Events.ToggleFavorite.self, transitionTo: GifDetail.States.TogglingFavorite(gif: state.gif, isFavorite: !state.isFavorite)) + } + + From(GifDetail.States.TogglingFavorite.self) { + On(GifDetail.Events.LoadingIsComplete.self) { event in + GifDetail.States.Loaded(gif: event.gif, isFavorite: event.isFavorite) + } + On(GifDetail.Events.LoadingHasFailed.self, transitionTo: GifDetail.States.Failed()) + } } } } diff --git a/Examples/Examples/GiphyApp/Features/GifDetail/System/GifDetail+Transitions.swift b/Examples/Examples/GiphyApp/Features/GifDetail/System/GifDetail+Transitions.swift deleted file mode 100644 index 83a1fde..0000000 --- a/Examples/Examples/GiphyApp/Features/GifDetail/System/GifDetail+Transitions.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// GifDetail+Transitions.swift -// Examples -// -// Created by Thibault Wittemberg on 2021-01-17. -// - -import Feedbacks - -extension GifDetail { - enum Transitions {} -} - -extension GifDetail.Transitions { - static let loadingTransitions = Transitions { - Transition(from: GifDetail.States.Loading.self, on: GifDetail.Events.LoadingIsComplete.self) { _, event in - GifDetail.States.Loaded(gif: event.gif, isFavorite: event.isFavorite) - } - - Transition(from: GifDetail.States.Loading.self, on: GifDetail.Events.LoadingHasFailed.self, then: GifDetail.States.Failed()) - } - - static let loadedTransition = Transition(from: GifDetail.States.Loaded.self, - on: GifDetail.Events.ToggleFavorite.self) { state, _ in - GifDetail.States.TogglingFavorite(gif: state.gif, isFavorite: !state.isFavorite) - } - - static let togglingTransitions = Transitions { - Transition(from: GifDetail.States.TogglingFavorite.self, on: GifDetail.Events.LoadingIsComplete.self) { _, event in - GifDetail.States.Loaded(gif: event.gif, isFavorite: event.isFavorite) - } - - Transition(from: GifDetail.States.TogglingFavorite.self, on: GifDetail.Events.LoadingHasFailed.self, then: GifDetail.States.Failed()) - } -} diff --git a/Examples/Examples/GiphyApp/Features/GifList/System/GifList+States.swift b/Examples/Examples/GiphyApp/Features/GifList/System/GifList+States.swift index 0aeb5cd..336aa66 100644 --- a/Examples/Examples/GiphyApp/Features/GifList/System/GifList+States.swift +++ b/Examples/Examples/GiphyApp/Features/GifList/System/GifList+States.swift @@ -13,15 +13,23 @@ extension GifList { } extension GifList.States { - struct Loading: State { + struct Loading: State, Equatable { var page: Int = 0 } - struct Loaded: State { + struct Loaded: State, Equatable { + static func == (lhs: GifList.States.Loaded, rhs: GifList.States.Loaded) -> Bool { + return + lhs.currentPage == rhs.currentPage && + lhs.totalPage == rhs.totalPage && + lhs.gifs.map { $0.0 } == rhs.gifs.map { $0.0 } && + lhs.gifs.map { $0.1 } == rhs.gifs.map { $0.1 } + } + let gifs: [(GifOverview, Bool)] let currentPage: Int let totalPage: Int } - struct Failed: State {} + struct Failed: State, Equatable {} } diff --git a/Examples/Examples/GiphyApp/Features/GifList/System/GifList+System.swift b/Examples/Examples/GiphyApp/Features/GifList/System/GifList+System.swift index 6865b16..4e2d650 100644 --- a/Examples/Examples/GiphyApp/Features/GifList/System/GifList+System.swift +++ b/Examples/Examples/GiphyApp/Features/GifList/System/GifList+System.swift @@ -14,7 +14,7 @@ extension GifList { } extension GifList.System { - static var gifOverview : System { + static var gifs : System { let loadSideEffect = SideEffect.make(GifList.SideEffects.load(loadPageFunction:isFavoriteFunction:state:), arg1: GifList.Dependencies.loadPage(page:), arg2: GifList.Dependencies.isFavorite(gifOverview:)) @@ -25,7 +25,7 @@ extension GifList.System { } Feedbacks { - Feedback(on: GifList.States.Loading.self , strategy: .cancelOnNewState, sideEffect: loadSideEffect) + Feedback(on: GifList.States.Loading.self , strategy: .cancelOnNewState, perform: loadSideEffect) .execute(on: DispatchQueue(label: "Load Gifs Queue")) } .onStateReceived { @@ -36,9 +36,33 @@ extension GifList.System { } Transitions { - GifList.Transitions.loadingTransitions - GifList.Transitions.loadedTransitions - GifList.Transitions.failedTransition + From(GifList.States.Loading.self) { + On(GifList.Events.LoadingIsComplete.self) { event in + GifList.States.Loaded(gifs: event.gifs, + currentPage: event.currentPage, + totalPage: event.totalPage) + } + + On(GifList.Events.LoadingHasFailed.self, transitionTo: GifList.States.Failed()) + } + + From(GifList.States.Loaded.self) { state in + On(GifList.Events.Refresh.self, transitionTo: GifList.States.Loading(page: state.currentPage)) + + On(GifList.Events.LoadPrevious.self) { + let previousPage = state.currentPage - 1 + return GifList.States.Loading(page: previousPage > 0 ? previousPage : 0) + } + + On(GifList.Events.LoadNext.self) { + let nextPage = state.currentPage + 1 + return GifList.States.Loading(page: nextPage < state.totalPage ? nextPage : state.totalPage) + } + } + + From(GifList.States.Failed.self) { + On(GifList.Events.Refresh.self, transitionTo: GifList.States.Loading()) + } } } } diff --git a/Examples/Examples/GiphyApp/Features/GifList/System/GifList+Transitions.swift b/Examples/Examples/GiphyApp/Features/GifList/System/GifList+Transitions.swift deleted file mode 100644 index 034f945..0000000 --- a/Examples/Examples/GiphyApp/Features/GifList/System/GifList+Transitions.swift +++ /dev/null @@ -1,43 +0,0 @@ -// -// GifList+Transitions.swift -// Examples -// -// Created by Thibault Wittemberg on 2021-01-16. -// - -import Feedbacks - -// define a namespace for the GifList transitions -extension GifList { - enum Transitions {} -} - -extension GifList.Transitions { - static let loadingTransitions = Transitions { - Transition(from: GifList.States.Loading.self, on: GifList.Events.LoadingIsComplete.self) { _, event -> State in - GifList.States.Loaded(gifs: event.gifs, - currentPage: event.currentPage, - totalPage: event.totalPage) - } - - Transition(from: GifList.States.Loading.self, on: GifList.Events.LoadingHasFailed.self, then: GifList.States.Failed()) - } - - static let loadedTransitions = Transitions { - Transition(from: GifList.States.Loaded.self, on: GifList.Events.Refresh.self) { state, _ -> State in - GifList.States.Loading(page: state.currentPage) - } - - Transition(from: GifList.States.Loaded.self, on: GifList.Events.LoadPrevious.self) { state, _ -> State in - let previousPage = state.currentPage - 1 - return GifList.States.Loading(page: previousPage > 0 ? previousPage : 0) - } - - Transition(from: GifList.States.Loaded.self, on: GifList.Events.LoadNext.self) { state, _ -> State in - let nextPage = state.currentPage + 1 - return GifList.States.Loading(page: nextPage < state.totalPage ? nextPage : state.totalPage) - } - } - - static let failedTransition = Transition(from: GifList.States.Failed.self, on: GifList.Events.Refresh.self, then: GifList.States.Loading()) -} diff --git a/Examples/Examples/GiphyApp/Features/GifList/Views/GifList+RootView.swift b/Examples/Examples/GiphyApp/Features/GifList/Views/GifList+RootView.swift index 1b43f77..10665e8 100644 --- a/Examples/Examples/GiphyApp/Features/GifList/Views/GifList+RootView.swift +++ b/Examples/Examples/GiphyApp/Features/GifList/Views/GifList+RootView.swift @@ -95,6 +95,6 @@ extension String: Identifiable { struct GifList_RootView_Previews: PreviewProvider { static var previews: some View { - GifList.RootView(system: GifList.System.gifOverview.uiSystem(viewStateFactory: GifList.ViewState.stateToViewState(state:))) + GifList.RootView(system: GifList.System.gifs.uiSystem(viewStateFactory: GifList.ViewState.stateToViewState(state:))) } } diff --git a/Examples/Examples/GiphyApp/Models/Domain/GifOverview.swift b/Examples/Examples/GiphyApp/Models/Domain/GifOverview.swift index b3f5db7..15fb674 100644 --- a/Examples/Examples/GiphyApp/Models/Domain/GifOverview.swift +++ b/Examples/Examples/GiphyApp/Models/Domain/GifOverview.swift @@ -5,7 +5,7 @@ // Created by Thibault Wittemberg on 2021-01-16. // -struct GifOverview: Decodable { +struct GifOverview: Decodable, Equatable { let type: String let id: String let title: String diff --git a/Examples/ExamplesTests/CounterApp/Counter+TransitionsTests.swift b/Examples/ExamplesTests/CounterApp/Counter+TransitionsTests.swift new file mode 100644 index 0000000..25aea19 --- /dev/null +++ b/Examples/ExamplesTests/CounterApp/Counter+TransitionsTests.swift @@ -0,0 +1,102 @@ +// +// Counter+TransitionsTests.swift +// ExamplesTests +// +// Created by Thibault Wittemberg on 2021-02-28. +// + +@testable import Examples +import Feedbacks +import FeedbacksTest +import XCTest + +final class Counter_TransitionsTests: XCTestCase { + let mockCounter = Counter(value: 5, min: 0, max: 10) + let shouldIncreaseMockCounter = Counter(value: 0, min: 0, max: 10) + let shouldDecreaseMockCounter = Counter(value: 10, min: 0, max: 10) + + func testTransitions_fromFixed_onToggle() { + let sut = CounterApp.System.counter.transitions + + sut.assertThat( + from: CounterApp.States.Fixed(counter: mockCounter), + on: CounterApp.Events.TogglePause(), + newStateIs: CounterApp.States.Decreasing(counter: mockCounter, isPaused: false) + ) + } + + func testTransitions_fromDecreasing_onToggle() { + let sut = CounterApp.System.counter.transitions + + sut.assertThat( + from: CounterApp.States.Decreasing(counter: mockCounter, isPaused: false), + on: CounterApp.Events.TogglePause(), + newStateIs: CounterApp.States.Decreasing(counter: mockCounter, isPaused: true) + ) + } + + func testTransitions_fromDecreasing_onDecrease() { + let sut = CounterApp.System.counter.transitions + + sut.assertThat( + from: CounterApp.States.Decreasing(counter: mockCounter, isPaused: false), + on: CounterApp.Events.Decrease(), + newStateIs: CounterApp.States.Decreasing(counter: mockCounter.decrease(), isPaused: false) + ) + } + + func testTransitions_fromDecreasing_onDecrease_when_at_min() { + let sut = CounterApp.System.counter.transitions + + sut.assertThat( + from: CounterApp.States.Decreasing(counter: shouldIncreaseMockCounter, isPaused: false), + on: CounterApp.Events.Decrease(), + newStateIs: CounterApp.States.Increasing(counter: shouldIncreaseMockCounter.increase(), isPaused: false) + ) + } + + func testTransitions_fromIncreasing_onToggle() { + let sut = CounterApp.System.counter.transitions + + sut.assertThat( + from: CounterApp.States.Increasing(counter: mockCounter, isPaused: false), + on: CounterApp.Events.TogglePause(), + newStateIs: CounterApp.States.Increasing(counter: mockCounter, isPaused: true) + ) + } + + func testTransitions_fromIncreasing_onIncrease() { + let sut = CounterApp.System.counter.transitions + + sut.assertThat( + from: CounterApp.States.Increasing(counter: mockCounter, isPaused: false), + on: CounterApp.Events.Increase(), + newStateIs: CounterApp.States.Increasing(counter: mockCounter.increase(), isPaused: false) + ) + } + + func testTransitions_fromIncreasing_onIncrease_when_at_max() { + let sut = CounterApp.System.counter.transitions + + sut.assertThat( + from: CounterApp.States.Increasing(counter: shouldDecreaseMockCounter, isPaused: false), + on: CounterApp.Events.Increase(), + newStateIs: CounterApp.States.Decreasing(counter: shouldDecreaseMockCounter.decrease(), isPaused: false) + ) + } + + func testTransitions_fromAny_onRefresh() { + let sut = CounterApp.System.counter.transitions + + let allStates: [State] = [ + CounterApp.States.Fixed(counter: mockCounter), + CounterApp.States.Decreasing(counter: mockCounter, isPaused: false), + CounterApp.States.Increasing(counter: mockCounter, isPaused: false) + ] + allStates.forEach { + sut.assertThat(from: $0, + on: CounterApp.Events.Reset(), + newStateIs: CounterApp.States.Fixed(counter: Counter(value: 10, min: 0, max: 10))) + } + } +} diff --git a/Examples/ExamplesTests/GiphyApp/GifDetail+TransitionsTests.swift b/Examples/ExamplesTests/GiphyApp/GifDetail+TransitionsTests.swift new file mode 100644 index 0000000..a9e8f6d --- /dev/null +++ b/Examples/ExamplesTests/GiphyApp/GifDetail+TransitionsTests.swift @@ -0,0 +1,61 @@ +// +// GifDetail+TransitionsTests.swift +// ExamplesTests +// +// Created by Thibault Wittemberg on 2021-02-28. +// + +@testable import Examples +import Feedbacks +import FeedbacksTest +import XCTest + +final class GifDetail_TransitionsTests: XCTestCase { + let mockGif = Gif(type: "type", + id: "id", + title: "title", + url: "url", + username: "username", + rating: "rating", + images: Images(fixedHeightData: ImageData(url: "url", mp4: "mp4"))) + + func testTransitions_fromLoading_onLoadingIsComplete() { + let sut = GifDetail.System.make(id: self.mockGif.id).transitions + + sut.assertThat(from: GifDetail.States.Loading(), + on: GifDetail.Events.LoadingIsComplete(gif: self.mockGif, isFavorite: true), + newStateIs: GifDetail.States.Loaded(gif: self.mockGif, isFavorite: true)) + } + + func testTransitions_fromLoading_onLoadingHasFailed() { + let sut = GifDetail.System.make(id: self.mockGif.id).transitions + + sut.assertThat(from: GifDetail.States.Loading(), + on: GifDetail.Events.LoadingHasFailed(), + newStateIs: GifDetail.States.Failed()) + } + + func testTransitions_fromLoaded_onToggleFavorite() { + let sut = GifDetail.System.make(id: self.mockGif.id).transitions + + sut.assertThat(from: GifDetail.States.Loaded(gif: self.mockGif, isFavorite: true), + on: GifDetail.Events.ToggleFavorite(), + newStateIs: GifDetail.States.TogglingFavorite(gif: self.mockGif, isFavorite: false)) + } + + func testTransitions_fromTogglingFavorite_onLoadingIsComplete() { + let sut = GifDetail.System.make(id: self.mockGif.id).transitions + + sut.assertThat(from: GifDetail.States.TogglingFavorite(gif: self.mockGif, isFavorite: true), + on: GifDetail.Events.LoadingIsComplete(gif: self.mockGif, isFavorite: true), + newStateIs: GifDetail.States.Loaded(gif: self.mockGif, isFavorite: true)) + } + + func testTransitions_fromTogglingFavorite_onLoadingHasFailed() { + let sut = GifDetail.System.make(id: self.mockGif.id).transitions + + sut.assertThat(from: GifDetail.States.TogglingFavorite(gif: self.mockGif, isFavorite: true), + on: GifDetail.Events.LoadingHasFailed(), + newStateIs: GifDetail.States.Failed()) + } +} diff --git a/Examples/ExamplesTests/GiphyApp/GifList+TransitionsTests.swift b/Examples/ExamplesTests/GiphyApp/GifList+TransitionsTests.swift new file mode 100644 index 0000000..7b0ed9c --- /dev/null +++ b/Examples/ExamplesTests/GiphyApp/GifList+TransitionsTests.swift @@ -0,0 +1,79 @@ +// +// GifList+TransitionsTests.swift +// ExamplesTests +// +// Created by Thibault Wittemberg on 2021-02-28. +// + +@testable import Examples +import Feedbacks +import FeedbacksTest +import XCTest + +final class GifList_TransitionsTests: XCTestCase { + let mockGifOverview = (GifOverview(type: "type", id: "id", title: "title", url: "url"), true) + + func testTransitions_fromLoading_onLoadingIsComplete() { + let sut = GifList.System.gifs.transitions + + sut.assertThat(from: GifList.States.Loading(page: 1), + on: GifList.Events.LoadingIsComplete(gifs: [self.mockGifOverview], currentPage: 1, totalPage: 10), + newStateIs: GifList.States.Loaded(gifs: [self.mockGifOverview], currentPage: 1, totalPage: 10)) + } + + func testTransitions_fromLoading_onLoadingHadFailed() { + let sut = GifList.System.gifs.transitions + + sut.assertThat(from: GifList.States.Loading(page: 1), + on: GifList.Events.LoadingHasFailed(), + newStateIs: GifList.States.Failed()) + } + + func testTransitions_fromLoaded_onRefresh() { + let sut = GifList.System.gifs.transitions + + sut.assertThat(from: GifList.States.Loaded(gifs: [self.mockGifOverview], currentPage: 1, totalPage: 10), + on: GifList.Events.Refresh(), + newStateIs: GifList.States.Loading(page: 1)) + } + + func testTransitions_fromLoaded_onLoadPrevious() { + let sut = GifList.System.gifs.transitions + + sut.assertThat(from: GifList.States.Loaded(gifs: [self.mockGifOverview], currentPage: 2, totalPage: 10), + on: GifList.Events.LoadPrevious(), + newStateIs: GifList.States.Loading(page: 1)) + } + + func testTransitions_fromLoaded_onLoadPrevious_when_page0() { + let sut = GifList.System.gifs.transitions + + sut.assertThat(from: GifList.States.Loaded(gifs: [self.mockGifOverview], currentPage: 0, totalPage: 10), + on: GifList.Events.LoadPrevious(), + newStateIs: GifList.States.Loading(page: 0)) + } + + func testTransitions_fromLoaded_onLoadNext() { + let sut = GifList.System.gifs.transitions + + sut.assertThat(from: GifList.States.Loaded(gifs: [self.mockGifOverview], currentPage: 2, totalPage: 10), + on: GifList.Events.LoadNext(), + newStateIs: GifList.States.Loading(page: 3)) + } + + func testTransitions_fromLoaded_onLoadNext_when_pageMax() { + let sut = GifList.System.gifs.transitions + + sut.assertThat(from: GifList.States.Loaded(gifs: [self.mockGifOverview], currentPage: 10, totalPage: 10), + on: GifList.Events.LoadNext(), + newStateIs: GifList.States.Loading(page: 10)) + } + + func testTransitions_fromFailed_onRefresh() { + let sut = GifList.System.gifs.transitions + + sut.assertThat(from: GifList.States.Failed(), + on: GifList.Events.Refresh(), + newStateIs: GifList.States.Loading()) + } +} diff --git a/Examples/ExamplesTests/Info.plist b/Examples/ExamplesTests/Info.plist new file mode 100644 index 0000000..64d65ca --- /dev/null +++ b/Examples/ExamplesTests/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/Package.swift b/Package.swift index 770ee9f..5b00105 100644 --- a/Package.swift +++ b/Package.swift @@ -16,6 +16,9 @@ let package = Package( .library( name: "Feedbacks", targets: ["Feedbacks"]), + .library( + name: "FeedbacksTest", + targets: ["FeedbacksTest"]), ], dependencies: [ // Dependencies declare other packages that this package depends on. @@ -26,9 +29,15 @@ let package = Package( // Targets can depend on other targets in this package, and on products in packages this package depends on. .target( name: "Feedbacks", - dependencies: []), + dependencies: [], + path: "Sources/Feedbacks"), .testTarget( name: "FeedbacksTests", - dependencies: ["Feedbacks", .product(name: "CombineSchedulers", package: "combine-schedulers")]), + dependencies: ["Feedbacks", "FeedbacksTest", .product(name: "CombineSchedulers", package: "combine-schedulers")], + path: "Tests/FeedbacksTests"), + .target( + name: "FeedbacksTest", + dependencies: ["Feedbacks"], + path: "Sources/FeedbacksTest"), ] ) diff --git a/README.md b/README.md index 2623d17..b2633cc 100644 --- a/README.md +++ b/README.md @@ -32,37 +32,34 @@ struct DecreaseEvent: Event {} let targetedVolume = 15 let system = System { - InitialState { - VolumeState(value: 10) - } - - Feedbacks { - Feedback(on: VolumeState.self, strategy: .continueOnNewState) { state -> AnyPublisher in - if state.value >= targetedVolume { - return Empty().eraseToAnyPublisher() - } - - return Just(IncreaseEvent()).eraseToAnyPublisher() - } - - Feedback(on: VolumeState.self, strategy: .continueOnNewState) { state -> AnyPublisher in - if state.value <= targetedVolume { - return Empty().eraseToAnyPublisher() - } - - return Just(DecreaseEvent()).eraseToAnyPublisher() - } - } - - Transitions { - Transition(from: VolumeState.self, on: IncreaseEvent.self) { state, event -> State in - VolumeState(value: state.value + 1) - } + InitialState { + VolumeState(value: 10) + } + + Feedbacks { + Feedback(on: VolumeState.self, strategy: .continueOnNewState) { state -> AnyPublisher in + if state.value >= targetedVolume { + return Empty().eraseToAnyPublisher() + } + + return Just(IncreaseEvent()).eraseToAnyPublisher() + } + + Feedback(on: VolumeState.self, strategy: .continueOnNewState) { state -> AnyPublisher in + if state.value <= targetedVolume { + return Empty().eraseToAnyPublisher() + } + + return Just(DecreaseEvent()).eraseToAnyPublisher() + } + } - Transition(from: VolumeState.self, on: DecreaseEvent.self) { state, event -> State in - VolumeState(value: state.value - 1) - } - } + Transitions { + From(VolumeState.self) { state in + On(IncreaseEvent.self, transitionTo: VolumeState(value: state.value + 1)) + On(DecreaseEvent.self, transitionTo: VolumeState(value: state.value - 1)) + } + } } ``` @@ -221,18 +218,25 @@ Feedback(...) Although it is recommended to describe all the possible transitions in a state machine, it is still possible to take some shortcuts with wildcards. ```swift -Transition(from: ErrorState.self, on: AnyEvent.Self, then: LoadingState()) +Transitions { + From(ErrorState.self) { + On(AnyEvent.self, transitionTo: LoadingState()) + } +} ``` Considering the state is ErrorState, this transition will produce a LoadingState whatever event is received. ```swift -Transition(from: AnyState.self, on: RefreshEvent.self, then: LoadingState()) +Transitions { + From(AnyState.self) { + On(RefreshEvent.self, transitionTo: LoadingState()) + } +} ``` Everytime the RefreshEvent is received, this transition will produce a LoadingState whatever the previous state. - ## The different ways of instantiating a Feedback A Feedback is built from a side effect. A side effect is a function that takes a state as a parameter. There are two ways to build a Feedback: @@ -262,13 +266,17 @@ The more complex a System, the more we need to add transitions. It's a good prac ```swift let transitions = Transitions { - Transitions { - Transition(from: LoadingState.self, on: DataIsLoaded.self, then: LoadedState()) - Transition(from: LoadingState.self, on: LoadingHasFailed.self, then: ErrorState()) + From(LoadingState.self) { state in + On(DataIsLoaded.self) { event in + LoadedState(page: state.page, data: event.data) + } + On(LoadingHasFailed.self, transitionTo: ErrorState()) } - - Transitions { - Transition(from: LoadedState.self, on: RefreshEvent.self, then: LoadingState()) + + From(LoadedState.self) { state in + On(RefreshEvent.self) { + LoadingState(page: state.page) + } } } ``` @@ -276,13 +284,17 @@ let transitions = Transitions { or even externalize them into properties: ```swift -let loadingTransitions = Transitions { - Transition(from: LoadingState.self, on: DataIsLoaded.self, then: LoadedState()) - Transition(from: LoadingState.self, on: LoadingHasFailed.self, then: ErrorState()) +let loadingTransitions = From(LoadingState.self) { state in + On(DataIsLoaded.self) { event in + LoadedState(page: state.page, data: event.data) + } + On(LoadingHasFailed.self, transitionTo: ErrorState()) } - -let loadedTransitions = Transitions { - Transition(from: LoadedState.self, on: RefreshEvent.self, then: LoadingState()) + +let loadedTransitions = From(LoadedState.self) { state in + On(RefreshEvent.self) { + LoadingState(page: state.page) + } } let transitions = Transitions { @@ -291,6 +303,17 @@ let transitions = Transitions { } ``` +## Unit testing you state machine + +In order to ease the testing of your transitions you can import the "FeedbacksTest" library. +It provides helper functions on the "Transitions" type. + +Once you have a system, you can retrieve its transitions: `let transitions = mySystem.transitions`: + +* `transitions.assertThat(from: VolumeState(value: 10), on: IncreaseEvent(), newStateIs: VolumeState(value: 11))` +* `transitions.assertThatStateIsUnchanged(from: Loading(), on: Refresh())` + + ## How to make Systems communicate? Systems should be self contained and limited to their business. We should pay attention to make them small and composable. It might occur that a feature is composed of several Systems. In that case we could want them to communicate together. diff --git a/Sources/Feedbacks/StateMachine/ArrayBuilder.swift b/Sources/Feedbacks/StateMachine/ArrayBuilder.swift new file mode 100644 index 0000000..2bf2a92 --- /dev/null +++ b/Sources/Feedbacks/StateMachine/ArrayBuilder.swift @@ -0,0 +1,13 @@ +// +// ArrayBuilder.swift +// +// +// Created by Thibault Wittemberg on 2021-02-21. +// + +@_functionBuilder +public struct ArrayBuilder { + public static func buildBlock(_ values: Value...) -> [Value] { + values + } +} diff --git a/Sources/Feedbacks/StateMachine/From.swift b/Sources/Feedbacks/StateMachine/From.swift new file mode 100644 index 0000000..4147b47 --- /dev/null +++ b/Sources/Feedbacks/StateMachine/From.swift @@ -0,0 +1,82 @@ +// +// From.swift +// +// +// Created by Thibault Wittemberg on 2021-02-21. +// + +/// `From` represent the state that a transition will use as the basis for a new state +public struct From { + let id: AnyHashable + let transitionsForState: (State) -> [On] + + /// Build a transition for a State type + /// - Parameters: + /// - stateType: the type of state for which transitions are possible + /// - transitionsForState: the possible transitions for the type of state + public init(_ stateType: StateType.Type, + @ArrayBuilder _ transitionsForState: @escaping (StateType) -> [On]) { + self.init(id: StateType.id) { state in + guard let concreteState = state as? StateType else { return [] } + return transitionsForState(concreteState) + } + } + + /// Build a transition for a State type + /// - Parameters: + /// - stateType: the type of state for which transitions are possible + /// - transitionsForState: the possible transitions for the type of state (not based on the current state) + public init(_ stateType: StateType.Type, + @ArrayBuilder _ transitionsForState: @escaping () -> [On]) { + self.init(id: StateType.id) { state in + guard state is StateType else { return [] } + return transitionsForState() + } + } + + /// Build a transition for a any state + /// - Parameters: + /// - stateType: the wildcard for any state + /// - transitionsForState: the possible transitions for any state + public init(_ stateType: AnyState.Type, + @ArrayBuilder _ transitionsForState: @escaping (State) -> [On]) { + self.init(id: AnyState.id, transitions: transitionsForState) + } + + /// Build a transition for a any state + /// - Parameters: + /// - stateType: the wildcard for any state + /// - transitionsForState: the possible transitions for any state (not based on the current state) + public init(_ stateType: AnyState.Type, + @ArrayBuilder _ transitionsForState: @escaping () -> [On]) { + self.init(id: AnyState.id, transitions: { _ in transitionsForState() }) + } + + init(id: AnyHashable, transitions: @escaping (State) -> [On]) { + self.id = id + self.transitionsForState = transitions + } + + func computeTransitionsForEvents(for state: State, existingTranstionsForState: ((State) -> [AnyHashable: (Event) -> State?])? = nil) -> [AnyHashable: (Event) -> State?] { + var transitionsForEvents = self.transitionsForState(state).reduce(into: [AnyHashable: (Event) -> State?]()) { accumulator, on in + accumulator[on.id] = on.transitionForEvent + } + + if let existingTransitionsForEvents = existingTranstionsForState?(state) { + transitionsForEvents.merge(existingTransitionsForEvents, uniquingKeysWith: { value1, value2 in value2 }) + } + + return transitionsForEvents + } +} + +public extension From { + /// Disables the transitions for this state type, as long as the `disabled` condition is true + /// - Parameter disabled: the condition that disables the transitions + /// - Returns: the `From` transition + func disable(_ disabled: @escaping () -> Bool) -> Self { + From(id: self.id) { state -> [On] in + return self.transitionsForState(state).map { $0.disable(disabled) } + } + } +} diff --git a/Sources/Feedbacks/StateMachine/On.swift b/Sources/Feedbacks/StateMachine/On.swift new file mode 100644 index 0000000..ce41193 --- /dev/null +++ b/Sources/Feedbacks/StateMachine/On.swift @@ -0,0 +1,96 @@ +// +// On.swift +// +// +// Created by Thibault Wittemberg on 2021-02-21. +// + +/// `On` represent the input that the state machine will react to. +public struct On { + let id: AnyHashable + let transitionForEvent: (Event) -> State? + + /// Build a transition for an Event Type + /// - Parameters: + /// - eventType: The event type that should trigger the transition + /// - transition: the transition to compute a new state based on the received event + public init (_ eventType: EventType.Type, transition: @escaping (EventType) -> State) { + self.id = EventType.id + self.transitionForEvent = { event in + guard let concreteEvent = event as? EventType else { return nil } + return transition(concreteEvent) + } + } + + /// Build a transition for an Event Type + /// - Parameters: + /// - eventType: The event type that should trigger the transition + /// - transition: the transition to compute a new state + public init (_ eventType: EventType.Type, transition: @escaping () -> State) { + self.id = EventType.id + self.transitionForEvent = { event in + guard event is EventType else { return nil } + return transition() + } + } + + /// Build a transition for an Event Type + /// - Parameters: + /// - eventType: The event type that should trigger the transition + /// - transitionTo: the new state + public init (_ eventType: EventType.Type, transitionTo: State) { + self.id = EventType.id + self.transitionForEvent = { event in + guard event is EventType else { return nil } + return transitionTo + } + } + + /// Build a transition for any event + /// - Parameters: + /// - eventType: the wildcard for any event + /// - transition: the transition to compute a new state based on the received event + public init (_ eventType: AnyEvent.Type, transition: @escaping (Event) -> State) { + self.id = AnyEvent.id + self.transitionForEvent = transition + } + + /// Build a transition for any event + /// - Parameters: + /// - eventType: the wildcard for any event + /// - transition: the transition to compute a new state based on the received event + public init (_ eventType: AnyEvent.Type, transition: @escaping () -> State) { + self.id = AnyEvent.id + self.transitionForEvent = { _ in + transition() + } + } + + /// Build a transition for any event + /// - Parameters: + /// - eventType: the wildcard for any event + /// - transitionTo: the new state + public init (_ eventType: AnyEvent.Type, transitionTo: State) { + self.id = AnyEvent.id + self.transitionForEvent = { _ in + transitionTo + } + } + + init(id: AnyHashable, transition: @escaping (Event) -> State?) { + self.id = id + self.transitionForEvent = transition + } +} + +public extension On { + /// Disables the transition as long as the `disabled` condition is true + /// - Parameter disabled: the condition that disables the transition + /// - Returns: the `On` transition + func disable(_ disabled: @escaping () -> Bool) -> Self { + On(id: self.id) { event -> State? in + guard !disabled() else { return nil } + return self.transitionForEvent(event) + } + } +} diff --git a/Sources/Feedbacks/StateMachine/Transition.swift b/Sources/Feedbacks/StateMachine/Transition.swift deleted file mode 100644 index 65bfd06..0000000 --- a/Sources/Feedbacks/StateMachine/Transition.swift +++ /dev/null @@ -1,77 +0,0 @@ -// -// Transition.swift -// -// -// Created by Thibault Wittemberg on 2020-12-21. -// - -/// A Transition describes the passage from a State to another, in reaction to an event. -/// The pair of input state and event forms a TransitionId, which identifies the Transition within a state machine. -/// As a TransitionId is Hashable, there cannot exist 2 Transitions with the same pair State/Event in one state machine. -/// In that case, the last declared transition will be the one executed. -public struct Transition: TransitionsDefinition, Equatable { - let transitionId: TransitionId - let reducer: (State, Event) -> State - - /// Build a Transition based on a State type and an Event type. The transition will produce a new state by executing the `then` reducer - /// - Parameters: - /// - stateType: The type of state that is concerned by this transition - /// - eventType: The type of event that is concerned by this transition - /// - reducer: The new state factory based on the received state and event - /// - /// `Transition(from: LoadingState.self, on: LoadedEvent.self, then: { _, event in return LoadedState(data: event.data) })` - public init( - from stateType: StateType.Type, - on eventType: EventType.Type, - then reducer: @escaping (StateType, EventType) -> State) { - self.transitionId = TransitionId(stateId: StateType.id, eventId: EventType.id) - self.reducer = { state, event in - guard - let concreteState = state as? StateType, - let concreteEvent = event as? EventType else { - return state - } - - return reducer(concreteState, concreteEvent) - } - } - - /// Build a Transition based on a State type and an Event type. The transition will produce the `newState` state - /// - Parameters: - /// - stateType: The type of state that is concerned by this transition - /// - eventType: The type of event that is concerned by this transition - /// - newState: The new state - public init( - from stateType: StateType.Type, - on eventType: EventType.Type, - then newState: State) { - self.init(from: stateType, on: eventType) { _, _ -> State in - newState - } - } - - init(transitionId: TransitionId, reducer: @escaping (State, Event) -> State) { - self.transitionId = transitionId - self.reducer = reducer - } - - public var entries: [TransitionId: (State, Event) -> State] { - [self.transitionId: self.reducer] - } - - public static func == (lhs: Transition, rhs: Transition) -> Bool { - lhs.entries.map { $0.key } == rhs.entries.map { $0.key } - } -} - -// MARK: modifiers -public extension Transition { - func disable(_ disabled: @escaping () -> Bool) -> Self { - let disabledReducer: (State, Event) -> State = { state, event in - guard !disabled() else { return state } - return self.reducer(state, event) - } - - return Transition(transitionId: self.transitionId, reducer: disabledReducer) - } -} diff --git a/Sources/Feedbacks/StateMachine/Transitions.swift b/Sources/Feedbacks/StateMachine/Transitions.swift index 787878f..3dbd39a 100644 --- a/Sources/Feedbacks/StateMachine/Transitions.swift +++ b/Sources/Feedbacks/StateMachine/Transitions.swift @@ -5,66 +5,68 @@ // Created by Thibault Wittemberg on 2020-12-23. // -/// Represents a series of Transitions to form a State Machine. -public struct Transitions: TransitionsDefinition { - let transitions: [TransitionsDefinition] +/// Represents a series of Transitions that drive a State Machine. +public struct Transitions { + let transitions: [From] + + /// the reducer computed from the state machine's transitions + public let reducer: (State, Event) -> State /// - Parameter transitions: the individual transitions composing the state machine - /// `Transitions {` - /// `Transition(from: LoadingState.self, on: LoadedEvent.self, then: LoadedState())` - /// `Transition(from: LoadingState.self, on: ErrorEvent.self, then: ErrorState())` - /// `}` - public init(@TransitionsDefinitionsBuilder _ transitions: () -> [TransitionsDefinition]) { - self.transitions = transitions() + /// Transitions { + /// From(Loading.self) { state in + /// On(LoadingHasComplete.self) { event in + /// Loaded() + /// } + /// } + /// From(Loading.self) { state in + /// On(LoadingHasFailed.self) { event in + /// Failed() + /// } + /// } + /// } + public init(@ArrayBuilder _ transitions: () -> [From]) { + self.init(transitions: transitions()) } - init(transitions: [TransitionsDefinition]) { + init(transitions: [From]) { self.transitions = transitions - } + let transitionsForStates = self.transitions.reduce(into: [AnyHashable: (State) -> [AnyHashable: (Event) -> State?]]()) { accumulator, from in + let existingTranstionsForState = accumulator[from.id] + accumulator[from.id] = { state in from.computeTransitionsForEvents(for: state, existingTranstionsForState: existingTranstionsForState) } + } + self.reducer = { state, event -> State in + if + let transitionsForState = transitionsForStates[state.instanceId], + let transitionForEvent = transitionsForState(state)[event.instanceId], + let newState = transitionForEvent(event) { return newState } + + if + let transitionsForState = transitionsForStates[state.instanceId], + let transitionForEvent = transitionsForState(state)[AnyEvent.id], + let newState = transitionForEvent(event) { return newState } + + if + let transitionsForState = transitionsForStates[AnyState.id], + let transitionForEvent = transitionsForState(state)[event.instanceId], + let newState = transitionForEvent(event) { return newState } - public var entries: [TransitionId: (State, Event) -> State] { - self.transitions.reduce(into: [TransitionId: (State, Event) -> State]()) { accumulator, transition in - accumulator.merge(transition.entries, uniquingKeysWith: { $1 }) + if + let transitionsForState = transitionsForStates[AnyState.id], + let transitionForEvent = transitionsForState(state)[AnyEvent.id], + let newState = transitionForEvent(event) { return newState } + + return state } } } // MARK: modifiers public extension Transitions { + /// Disables all the transitions of the state machine, as long as the `disabled` condition is true + /// - Parameter disabled: the condition that disables the transitions + /// - Returns: the transitions func disable(_ disabled: @escaping () -> Bool) -> Self { - let disabledTransitions = self.transitions.map { $0.disable(disabled) } - - return Transitions(transitions: disabledTransitions) - } -} - -public extension Transitions { - var reducer: (State, Event) -> State { - let entries = self.entries - - return { state, event -> State in - let transitionId = TransitionId(stateId: state.instanceId, eventId: event.instanceId) - - if let reducer = entries[transitionId] { - return reducer(state, event) - } - - let transitionAnyEventId = TransitionId(stateId: state.instanceId, eventId: AnyEvent.id) - if let reducer = entries[transitionAnyEventId] { - return reducer(state, AnyEvent()) - } - - let transitionAnyStateId = TransitionId(stateId: AnyState.id, eventId: event.instanceId) - if let reducer = entries[transitionAnyStateId] { - return reducer(AnyState(), event) - } - - let transitionAnyId = TransitionId(stateId: AnyState.id, eventId: AnyEvent.id) - if let reducer = entries[transitionAnyId] { - return reducer(AnyState(), AnyEvent()) - } - - return state - } + Transitions(transitions: self.transitions.map { $0.disable(disabled) }) } } diff --git a/Sources/Feedbacks/StateMachine/TransitionsDefinition.swift b/Sources/Feedbacks/StateMachine/TransitionsDefinition.swift deleted file mode 100644 index 81a4b2b..0000000 --- a/Sources/Feedbacks/StateMachine/TransitionsDefinition.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// Transitions.swift -// -// -// Created by Thibault Wittemberg on 2020-12-23. -// - -/// Represent an entry in the state machine's transitions. -/// As it is Hashable, a TransitionId is unique in a state machine. -public struct TransitionId: Hashable { - let stateId: AnyHashable - let eventId: AnyHashable - - public init(stateId: AnyHashable, eventId: AnyHashable) { - self.stateId = stateId - self.eventId = eventId - } -} - -public protocol TransitionsDefinition { - var entries: [TransitionId: (State, Event) -> State] { get } - - /// Disables the transition while the `disabled` condition is true. - /// The condition is re-evaluated each time the transition can be applied/ - /// - Parameter disabled: return true to disable the transition - func disable(_ disabled: @escaping () -> Bool) -> Self -} - -@_functionBuilder -public struct TransitionsDefinitionsBuilder { - public static func buildBlock(_ transitions: TransitionsDefinition...) -> [TransitionsDefinition] { - transitions - } -} - -@_functionBuilder -public struct TransitionsBuilder { - public static func buildBlock(_ transitions: TransitionsDefinition...) -> Transitions { - Transitions(transitions: transitions) - } -} diff --git a/Sources/Feedbacks/System/Feedback.swift b/Sources/Feedbacks/System/Feedback.swift index 0ec7d48..bb3c7c4 100644 --- a/Sources/Feedbacks/System/Feedback.swift +++ b/Sources/Feedbacks/System/Feedback.swift @@ -45,7 +45,7 @@ public struct Feedback { /// - on: The type of state that should trigger the side effect (forced to AnyState) /// - sideEffect: the side effect to execute in the context of this feedback public init(on: AnyState.Type, - sideEffect: @escaping (AnyPublisher) -> AnyPublisher) { + perform sideEffect: @escaping (AnyPublisher) -> AnyPublisher) { self.init(sideEffect: sideEffect) } @@ -58,8 +58,8 @@ public struct Feedback { public init(on: AnyState.Type, strategy: Feedback.Strategy, willExecuteWithStrategy: @escaping (Feedback.Strategy) -> Void = { _ in }, - sideEffect: @escaping (State) -> AnyPublisher) { - self.init(on: AnyState.self, sideEffect: strategy.apply(on: sideEffect, willExecuteWithStrategy: willExecuteWithStrategy)) + perform sideEffect: @escaping (State) -> AnyPublisher) { + self.init(on: AnyState.self, perform: strategy.apply(on: sideEffect, willExecuteWithStrategy: willExecuteWithStrategy)) } /// Creates a Feedback based on a side effect to execute @@ -67,7 +67,7 @@ public struct Feedback { /// - on: The type of state that should trigger the side effect /// - sideEffect: the side effect to execute in the context of this feedback public init(on: StateType.Type, - sideEffect: @escaping (AnyPublisher) -> AnyPublisher) { + perform sideEffect: @escaping (AnyPublisher) -> AnyPublisher) { let wrappingSideEffect: (AnyPublisher) -> AnyPublisher = { states in sideEffect(states.compactMap { $0 as? StateType }.eraseToAnyPublisher()) } @@ -83,8 +83,8 @@ public struct Feedback { public init(on: StateType.Type, strategy: Feedback.Strategy, willExecuteWithStrategy: @escaping (Feedback.Strategy) -> Void = { _ in }, - sideEffect: @escaping (StateType) -> AnyPublisher) { - self.init(on: StateType.self, sideEffect: strategy.apply(on: sideEffect, willExecuteWithStrategy: willExecuteWithStrategy)) + perform sideEffect: @escaping (StateType) -> AnyPublisher) { + self.init(on: StateType.self, perform: strategy.apply(on: sideEffect, willExecuteWithStrategy: willExecuteWithStrategy)) } } diff --git a/Sources/Feedbacks/System/System.swift b/Sources/Feedbacks/System/System.swift index 16f66e8..50ceb85 100644 --- a/Sources/Feedbacks/System/System.swift +++ b/Sources/Feedbacks/System/System.swift @@ -17,7 +17,7 @@ import Foundation public class System { let initialState: InitialState var feedbacks: Feedbacks - let transitions: Transitions + public let transitions: Transitions var scheduledStream: (AnyPublisher) -> AnyPublisher private var subscriptions = [AnyCancellable]() diff --git a/Sources/FeedbacksTest/StateMachine/Transitions+AssertThat.swift b/Sources/FeedbacksTest/StateMachine/Transitions+AssertThat.swift new file mode 100644 index 0000000..65a6456 --- /dev/null +++ b/Sources/FeedbacksTest/StateMachine/Transitions+AssertThat.swift @@ -0,0 +1,24 @@ +// +// Transitions+AssertThat.swift +// +// +// Created by Thibault Wittemberg on 2021-02-27. +// + +import Feedbacks +import XCTest + +public extension Transitions { + func assertThat(from state: State, + on event: Event, + newStateIs expectedState: OutputStateType) { + let receivedState = self.reducer(state, event) as? OutputStateType + XCTAssertEqual(receivedState, expectedState) + } + + func assertThatStateIsUnchanged(from state: InputStateType, + on event: Event) { + let receivedState = self.reducer(state, event) as? InputStateType + XCTAssertEqual(receivedState, state) + } +} diff --git a/Tests/FeedbacksTests/StateMachine/ArrayBuilderTests.swift b/Tests/FeedbacksTests/StateMachine/ArrayBuilderTests.swift new file mode 100644 index 0000000..44c252d --- /dev/null +++ b/Tests/FeedbacksTests/StateMachine/ArrayBuilderTests.swift @@ -0,0 +1,25 @@ +// +// ArrayBuilderTests.swift +// +// +// Created by Thibault Wittemberg on 2021-02-21. +// + +import Feedbacks +import XCTest + +final class ArrayBuilderTests: XCTestCase { + @ArrayBuilder + var mockEntries: [Int] { + 1 + 2 + 3 + 4 + 5 + } + + func testBuildBlock_gathers_entries_into_an_array() { + XCTAssertEqual(self.mockEntries, [1, 2, 3, 4, 5]) + + } +} diff --git a/Tests/FeedbacksTests/StateMachine/FromTests.swift b/Tests/FeedbacksTests/StateMachine/FromTests.swift new file mode 100644 index 0000000..b448d1a --- /dev/null +++ b/Tests/FeedbacksTests/StateMachine/FromTests.swift @@ -0,0 +1,262 @@ +// +// FromTests.swift +// +// +// Created by Thibault Wittemberg on 2021-02-21. +// + +@testable import Feedbacks +import XCTest + +private struct MockState: State, Equatable { let value: Int } +private struct AnotherMockState: State, Equatable { let value: Int } +private struct MockEvent: Event, Equatable { let value: Int } +private struct AnotherMockEvent: Event {} + +final class FromTests: XCTestCase { + func testTransitionsForState_has_no_transitions_when_state_is_not_expectedType() { + // Given: a From handling a MockState + let sut = From(MockState.self) { _ in + On(AnyEvent.self) { _ in + return MockState(value: Int.random(in: 1...1_000_000)) + } + } + + // When: giving the From an unexpected state type + let receivedOns = sut.transitionsForState(AnotherMockState(value: 1)) + + // Then: no transitions are computed + XCTAssertEqual(sut.id, MockState.id) + XCTAssertTrue(receivedOns.isEmpty) + } + + func testTransitionsForState_has_no_transitions_when_state_is_not_expectedType_with_parameterLess_resultBuilder() { + // Given: a From handling a MockState + let sut = From(MockState.self) { + On(AnyEvent.self) { _ in + return MockState(value: Int.random(in: 1...1_000_000)) + } + } + + // When: giving the From an unexpected state type + let receivedOns = sut.transitionsForState(AnotherMockState(value: 1)) + + // Then: no transitions are computed + XCTAssertEqual(sut.id, MockState.id) + XCTAssertTrue(receivedOns.isEmpty) + } + + func testTransitionsForState_has_transitions_when_state_is_expectedType() { + let expectedState = MockState(value: Int.random(in: 1...1_000_000)) + var receivedState: MockState? + + let expectedEvent = MockEvent(value: Int.random(in: 1...1_000_000)) + var receivedEvent: MockEvent? + + let expectedNewState = MockState(value: Int.random(in: 1...1_000_000)) + + // Given: a From handling a MockState + let sut = From(MockState.self) { state in + On(MockEvent.self) { event in + receivedState = state + receivedEvent = event + return expectedNewState + } + } + + // When: giving the From an expected state type + let receivedOns = sut.transitionsForState(expectedState) + + // Then: the From has 1 On transition with the expected behavior + XCTAssertEqual(sut.id, MockState.id) + XCTAssertEqual(receivedOns.count, 1) + XCTAssertEqual(receivedOns.first!.id, MockEvent.id) + XCTAssertEqual(receivedOns.first!.transitionForEvent(expectedEvent) as? MockState, expectedNewState) + XCTAssertEqual(receivedState, expectedState) + XCTAssertEqual(receivedEvent, expectedEvent) + } + + func testTransitionsForState_has_transitions_when_state_is_expectedType_with_parameterLess_resultBuilder() { + let expectedState = MockState(value: Int.random(in: 1...1_000_000)) + let expectedEvent = MockEvent(value: Int.random(in: 1...1_000_000)) + let expectedNewState = MockState(value: Int.random(in: 1...1_000_000)) + + // Given: a From handling a MockState + let sut = From(MockState.self) { + On(MockEvent.self) { _ in + return expectedNewState + } + } + + // When: giving the From an expected state type + let receivedOns = sut.transitionsForState(expectedState) + + // Then: the From has 1 On transition with the expected behavior + XCTAssertEqual(sut.id, MockState.id) + XCTAssertEqual(receivedOns.count, 1) + XCTAssertEqual(receivedOns.first!.id, MockEvent.id) + XCTAssertEqual(receivedOns.first!.transitionForEvent(expectedEvent) as? MockState, expectedNewState) + } + + func testTransitionsForState_has_transitions_when_anyState() { + let expectedState = AnotherMockState(value: Int.random(in: 1...1_000_000)) + var receivedState: State? + + let expectedEvent = MockEvent(value: Int.random(in: 1...1_000_000)) + var receivedEvent: MockEvent? + + let expectedNewState = MockState(value: Int.random(in: 1...1_000_000)) + + // Given: a From handling AnyState + let sut = From(AnyState.self) { state in + On(MockEvent.self) { event in + receivedState = state + receivedEvent = event + return expectedNewState + } + } + + // When: giving the From whatever state type + let receivedOns = sut.transitionsForState(expectedState) + + // Then: the From has 1 On transition with the expected behavior + XCTAssertEqual(sut.id, AnyState.id) + XCTAssertEqual(receivedOns.count, 1) + XCTAssertEqual(receivedOns.first!.id, MockEvent.id) + XCTAssertEqual(receivedOns.first!.transitionForEvent(expectedEvent) as? MockState, expectedNewState) + XCTAssertEqual(receivedState as? AnotherMockState, expectedState) + XCTAssertEqual(receivedEvent, expectedEvent) + } + + func testTransitionsForState_has_transitions_when_anyState_with_parameterLess_resultBuilder() { + let expectedState = AnotherMockState(value: Int.random(in: 1...1_000_000)) + let expectedEvent = MockEvent(value: Int.random(in: 1...1_000_000)) + let expectedNewState = MockState(value: Int.random(in: 1...1_000_000)) + + // Given: a From handling AnyState + let sut = From(AnyState.self) { + On(MockEvent.self) { event in + return expectedNewState + } + } + + // When: giving the From whatever state type + let receivedOns = sut.transitionsForState(expectedState) + + // Then: the From has 1 On transition with the expected behavior + XCTAssertEqual(sut.id, AnyState.id) + XCTAssertEqual(receivedOns.count, 1) + XCTAssertEqual(receivedOns.first!.id, MockEvent.id) + XCTAssertEqual(receivedOns.first!.transitionForEvent(expectedEvent) as? MockState, expectedNewState) + } + + func testComputeTransitionsForEvents_return_empty_when_state_is_not_expectedType() { + // Given: a From handling a MockState + let sut = From(MockState.self) { state in + On(AnyEvent.self) { event in + return MockState(value: Int.random(in: 1...1_000_000)) + } + } + + // When: giving the From an unexpected state type + let receivedTransitionsForEvents = sut.computeTransitionsForEvents(for: AnotherMockState(value: 1)) + + // Then: no transitions are computed + XCTAssertTrue(receivedTransitionsForEvents.isEmpty) + } + + func testComputeTransitionsForEvents_return_transitionsForEvents_when_state_is_expectedType() { + let expectedNewState = MockState(value: Int.random(in: 1...1_000_000)) + + // Given: a From handling a MockState + let sut = From(MockState.self) { state in + On(MockEvent.self) { event in + return expectedNewState + } + } + + // When: giving the From an expected state type + let computeTransitionsForEvents = sut.computeTransitionsForEvents(for: MockState(value: Int.random(in: 1...1_000_000))) + + // Then: the From has 1 On transition with the expected behavior + XCTAssertEqual(computeTransitionsForEvents.count, 1) + XCTAssertEqual(computeTransitionsForEvents.first!.key, MockEvent.id) + XCTAssertEqual(computeTransitionsForEvents.first!.value(MockEvent(value: Int.random(in: 1...1_000_000))) as? MockState, expectedNewState) + } + + func testComputeTransitionsForEvents_overwrite_transitionsForEvents_when_same_eventId() { + // Given: 2 transitions from MockState/MockEvent + let sut = From(MockState.self) { + On(MockEvent.self, transitionTo: MockState(value: 1)) + On(MockEvent.self, transitionTo: MockState(value: 2)) + } + + // When: computing the transitions for events + let transitionForEvents = sut.computeTransitionsForEvents(for: MockState(value: 1)) + + // Then: only one transition is kept + // Then: the last transition is kept + XCTAssertEqual(transitionForEvents.count, 1) + XCTAssertEqual(transitionForEvents.first!.key, MockEvent.id) + XCTAssertEqual(transitionForEvents.first!.value(MockEvent(value: 1)) as? MockState, MockState(value: 2)) + } + + func testComputeTransitionsForEvents_merge_transitionsForStates_when_existing_transitions() { + // Given: 1 `From` transition for MockState/MockEvent + let sut = From(MockState.self) { + On(MockEvent.self, transitionTo: MockState(value: 1)) + } + + // When: computing the transitions for events based on an existing set of transitions for the same state id + let transitionForEvents = sut.computeTransitionsForEvents(for: MockState(value: 1)) { _ in [AnotherMockEvent.id: { _ in MockState(value: 2) }] } + + // Then: 2 transitions are computed for the MockState id + XCTAssertEqual(transitionForEvents.count, 2) + XCTAssertTrue(transitionForEvents.contains(where: { $0.key == MockEvent.id })) + XCTAssertTrue(transitionForEvents.contains(where: { $0.key == AnotherMockEvent.id })) + XCTAssertEqual(transitionForEvents[MockEvent.id]?(MockEvent(value: 1)) as? MockState, MockState(value: 1)) + XCTAssertEqual(transitionForEvents[AnotherMockEvent.id]?(AnotherMockEvent()) as? MockState, MockState(value: 2)) + } + + func testComputeTransitionsForEvents_merge_transitionsForStates_when_existing_transitions_with_overwrite_when_same_eventId() { + // Given: 1 `From` transition for MockState/MockEvent + let sut = From(MockState.self) { + On(MockEvent.self, transitionTo: MockState(value: 1)) + } + + // When: computing the transitions for events based on an existing set of transitions for the same state id and the same event id + let transitionForEvents = sut.computeTransitionsForEvents(for: MockState(value: 1)) { _ in [MockEvent.id: { _ in MockState(value: 2) }] } + + // Then: 1 transition is computed for the MockState id (the last one) + XCTAssertEqual(transitionForEvents.count, 1) + XCTAssertEqual(transitionForEvents[MockEvent.id]?(MockEvent(value: 1)) as? MockState, MockState(value: 2)) + } + + func testDisable_compute_new_state_only_when_not_disabled() { + let expectedNewState = MockState(value: Int.random(in: 1...1_000_000)) + var condition = true + + // Given: a From disabled when condition is true + let sut = From(MockState.self) { state in + On(MockEvent.self) { event in + return expectedNewState + } + }.disable { + condition == true + } + + // When: executing the declared transition + let receivedNewStateWhenDisabled = sut.transitionsForState(MockState(value: 1)).first!.transitionForEvent(MockEvent(value: 1)) + + // Then: the new state is nil + XCTAssertNil(receivedNewStateWhenDisabled) + + condition = false + + // When: executing the declared transition with condition to false + let receivedNewStateWhenEnabled = sut.transitionsForState(MockState(value: 1)).first!.transitionForEvent(MockEvent(value: 1)) + + // Then: the new state is the expected one + XCTAssertEqual(receivedNewStateWhenEnabled as? MockState, expectedNewState) + } +} diff --git a/Tests/FeedbacksTests/StateMachine/OnTests.swift b/Tests/FeedbacksTests/StateMachine/OnTests.swift new file mode 100644 index 0000000..ae3100c --- /dev/null +++ b/Tests/FeedbacksTests/StateMachine/OnTests.swift @@ -0,0 +1,186 @@ +// +// OnTests.swift +// +// +// Created by Thibault Wittemberg on 2021-02-21. +// + +@testable import Feedbacks +import XCTest + +private struct MockState: State, Equatable { let value: Int } +private struct AnotherMockState: State, Equatable { let value: Int } +private struct MockEvent: Event, Equatable { let value: Int } +private struct AnotherMockEvent: Event {} + +final class OnTests: XCTestCase { + func testTransitionForEvent_return_the_declared_state_when_called_with_an_event_of_the_declared_type() { + let expectedEvent = MockEvent(value: Int.random(in: 1...1_000_000)) + var receivedEvent: MockEvent? + + let expectedState = MockState(value: Int.random(in: 1...1_000_000)) + + // Given: an On that expect a MockEvent type and return an expected state + let sut = On(MockEvent.self) { event in + receivedEvent = event + return expectedState + } + + // When: calling the underlying transition with the expected MockEvent type + let receivedState = sut.transitionForEvent(expectedEvent) + + // Then: the received event is the expected one + XCTAssertEqual(receivedEvent, expectedEvent) + + // Then: the received state is the expected one + XCTAssertEqual(receivedState as? MockState, expectedState) + } + + func testTransitionForEvent_return_the_declared_state_when_called_with_an_event_of_the_declared_type_without_parameter() { + let expectedEvent = MockEvent(value: Int.random(in: 1...1_000_000)) + + let expectedState = MockState(value: Int.random(in: 1...1_000_000)) + + // Given: an On that expect a MockEvent type and return an expected state + let sut = On(MockEvent.self) { + return expectedState + } + + // When: calling the underlying transition with the expected MockEvent type + let receivedState = sut.transitionForEvent(expectedEvent) + + // Then: the received state is the expected one + XCTAssertEqual(receivedState as? MockState, expectedState) + } + + func testTransitionForEvent_return_nil_when_called_with_an_event_not_of_the_declared_type_without_parameter() { + let expectedEvent = AnotherMockEvent() + + let expectedState = MockState(value: Int.random(in: 1...1_000_000)) + + // Given: an On that expect a MockEvent type and return an expected state + let sut = On(MockEvent.self) { + return expectedState + } + + // When: calling the underlying transition with an unexpected Event type + let receivedState = sut.transitionForEvent(expectedEvent) + + // Then: the received state is nil + XCTAssertNil(receivedState) + } + + func testTransitionForEvent_return_the_declared_state_when_called_with_an_event_of_the_declared_type_with_transitionTo() { + let expectedEvent = MockEvent(value: Int.random(in: 1...1_000_000)) + let expectedState = MockState(value: Int.random(in: 1...1_000_000)) + + // Given: an On that expect a MockEvent type and return an expected state + let sut = On(MockEvent.self, transitionTo: expectedState) + + // When: calling the underlying transition with the expected MockEvent type + let receivedState = sut.transitionForEvent(expectedEvent) + + // Then: the received state is the expected one + XCTAssertEqual(receivedState as? MockState, expectedState) + } + + func testTransitionForEvent_return_nil_when_called_with_an_event_not_of_the_declared_type() { + let expectedState = MockState(value: Int.random(in: 1...1_000_000)) + + // Given: an On that expect a MockEvent type and return an expected state + let sut = On(MockEvent.self) { event in + expectedState + } + + // When: calling the underlying transition with an unexpected AnotherMockEvent type + // Then: the call returns nil + XCTAssertNil(sut.transitionForEvent(AnotherMockEvent())) + } + + func testTransitionForEvent_return_nil_when_called_with_an_event_not_of_the_declared_type_with_transitionTo() { + let expectedState = MockState(value: Int.random(in: 1...1_000_000)) + + // Given: an On that expect a MockEvent type and return an expected state + let sut = On(MockEvent.self, transitionTo: expectedState) + + // When: calling the underlying transition with an unexpected AnotherMockEvent type + // Then: the call returns nil + XCTAssertNil(sut.transitionForEvent(AnotherMockEvent())) + } + + func testTransitionForEvent_return_the_declared_state_when_called_with_AnyEvent() { + let expectedEvent = MockEvent(value: Int.random(in: 1...1_000_000)) + var receivedEvent: Event? + + let expectedState = MockState(value: Int.random(in: 1...1_000_000)) + + // Given: an On reacting to any event + let sut = On(AnyEvent.self) { event in + receivedEvent = event + return expectedState + } + + // When: calling the underlying transition with the expected MockEvent type + let receivedState = sut.transitionForEvent(expectedEvent) + + // Then: the received event is the expected one + XCTAssertEqual(receivedEvent as? MockEvent, expectedEvent) + + // Then: the received state is the expected one + XCTAssertEqual(receivedState as? MockState, expectedState) + } + + func testTransitionForEvent_return_the_declared_state_when_called_with_AnyEvent_without_parameter() { + let expectedEvent = MockEvent(value: Int.random(in: 1...1_000_000)) + let expectedState = MockState(value: Int.random(in: 1...1_000_000)) + + // Given: an On reacting to any event + let sut = On(AnyEvent.self) { + return expectedState + } + + // When: calling the underlying transition with the expected MockEvent type + let receivedState = sut.transitionForEvent(expectedEvent) + + // Then: the received state is the expected one + XCTAssertEqual(receivedState as? MockState, expectedState) + } + + func testTransitionForEvent_return_the_declared_state_when_called_with_AnyEvent_with_transitionTo() { + let expectedEvent = MockEvent(value: Int.random(in: 1...1_000_000)) + let expectedState = MockState(value: Int.random(in: 1...1_000_000)) + + // Given: an On reacting to any event + let sut = On(AnyEvent.self, transitionTo: expectedState) + + // When: calling the underlying transition with the expected MockEvent type + let receivedState = sut.transitionForEvent(expectedEvent) + + // Then: the received state is the expected one + XCTAssertEqual(receivedState as? MockState, expectedState) + } + + func testDisable_do_not_execute_the_transition_when_is_disabled() { + let expectedState = MockState(value: Int.random(in: 1...1_000_000)) + var condition = true + + // Given: an On that is disabled depending on the "condition" value + let sut = On(MockEvent.self) { event in + expectedState + }.disable { + condition == true + } + + // When: calling the underlying transition while disabled + // Then: the call returns nil + XCTAssertNil(sut.transitionForEvent(AnotherMockEvent())) + + condition = false + + // When: calling the underlying transition while not disabled + let receivedState = sut.transitionForEvent(MockEvent(value: Int.random(in: 1...1_000_000))) + + // Then: the received state is the expected one + XCTAssertEqual(receivedState as? MockState, expectedState) + } +} diff --git a/Tests/FeedbacksTests/StateMachine/TransitionTests.swift b/Tests/FeedbacksTests/StateMachine/TransitionTests.swift deleted file mode 100644 index dfe1c8d..0000000 --- a/Tests/FeedbacksTests/StateMachine/TransitionTests.swift +++ /dev/null @@ -1,141 +0,0 @@ -// -// TransitionTests.swift -// -// -// Created by Thibault Wittemberg on 2020-12-24. -// - -import Feedbacks -import XCTest - -private struct MockState: State, Equatable { let value: Int } -private struct AnotherMockState: State, Equatable { let value: Int } -private struct WrongMockState: State {} - -private struct MockEvent: Event {} -private struct AnotherMockEvent: Event {} -private struct WrongMockEvent: Event {} - -final class TransitionTests: XCTestCase { - func testInit_use_stateId_and_eventId_to_register_the_reducer() { - var transitionIsCalled = false - - // Given: a transition for a state type and an event type - let sut = Transition(from: MockState.self, on: MockEvent.self) { _, _ in - transitionIsCalled = true - return MockState(value: 2) - } - - // When: retrieving the reducer matching the transition id - let transitionId = TransitionId(stateId: MockState.id, eventId: MockEvent.id) - let receivedReducer = sut.entries[transitionId]! - - // When: calling that reducer - _ = receivedReducer(MockState(value: 1), MockEvent()) - - // Then: the transition's reducer is called - XCTAssertTrue(transitionIsCalled) - } - - func testInit_make_a_reducer_that_accept_only_expected_stateType() { - var transitionIsCalled = false - - // Given: a transition for a state type and an event type - let sut = Transition(from: MockState.self, on: MockEvent.self) { _, _ in - transitionIsCalled = true - return MockState(value: 2) - } - - // When: retrieving the reducer matching the transition id - let transitionId = TransitionId(stateId: MockState.id, eventId: MockEvent.id) - let receivedReducer = sut.entries[transitionId]! - - // When: calling that reducer with an unexpected state type - _ = receivedReducer(WrongMockState(), MockEvent()) - - // Then: the transition's reducer is not called - XCTAssertFalse(transitionIsCalled) - } - - func testInit_make_a_reducer_that_accept_only_expected_eventType() { - var transitionIsCalled = false - - // Given: a transition for a state type and an event type - let sut = Transition(from: MockState.self, on: MockEvent.self) { _, _ in - transitionIsCalled = true - return MockState(value: 2) - } - - // When: retrieving the reducer matching the transition id - let transitionId = TransitionId(stateId: MockState.id, eventId: MockEvent.id) - let receivedReducer = sut.entries[transitionId]! - - // When: calling that reducer with an unexpected event type - _ = receivedReducer(MockState(value: 1), WrongMockEvent()) - - // Then: the transition's reducer is not called - XCTAssertFalse(transitionIsCalled) - } - - func testInit_make_a_reducer_returning_the_thenState() { - let expectedState = MockState(value: Int.random(in: 1...1_000_000)) - - // Given: a transition for a state type and an event type, returning a known new state - let sut = Transition(from: MockState.self, on: MockEvent.self, then: expectedState) - - // When: retrieving the reducer matching the transition id - let transitionId = TransitionId(stateId: MockState.id, eventId: MockEvent.id) - let receivedReducer = sut.entries[transitionId]! - - // When: calling that reducer - let receivedState = receivedReducer(MockState(value: 1), MockEvent()) - - // Then: the transition's reducer returns the expected state - XCTAssertEqual(receivedState as? MockState, expectedState) - } - - func testDisable_dynamically_disable_the_transition() { - var isDisabled = true - - // Given: a transition that is disabled when isDisabled is true - let sut = Transition(from: MockState.self, on: MockEvent.self, then: AnotherMockState(value: 1)) - .disable { isDisabled } - - let reducer = sut.entries.first!.value - - // When: executing its associated reducer when isDisabled is true - let inputState1 = MockState(value: Int.random(in: 0...1_000_000)) - let newState1 = reducer(inputState1, MockEvent()) - // Then: the newState1 is the one from the input - XCTAssertEqual(newState1 as? MockState, inputState1) - - isDisabled = false - - // When: executing its associated reducer when isDisabled is false - let newState2 = reducer(MockState(value: 1), MockEvent()) - // Then: the newState2 is the one declared in the transition - XCTAssertEqual(newState2 as? AnotherMockState, AnotherMockState(value: 1)) - - isDisabled = true - - // When: executing its associated reducer when isDisabled is true - let inputState3 = MockState(value: Int.random(in: 0...1_000_000)) - let newState3 = reducer(inputState3, MockEvent()) - // Then: the newState3 is the one from the input - XCTAssertEqual(newState3 as? MockState, inputState3) - } -} - -extension TransitionTests { - func testEquality() { - // Given: 3 transitions - let transitionA = Transition(from: MockState.self, on: MockEvent.self, then: MockState(value: 1)) - let transitionB = Transition(from: AnotherMockState.self, on: AnotherMockEvent.self, then: MockState(value: 2)) - let transitionC = Transition(from: MockState.self, on: MockEvent.self, then: MockState(value: 2)) - - // When: comparing them for their equality - // Then: A == C and A != B - XCTAssertEqual(transitionA, transitionC) - XCTAssertNotEqual(transitionA, transitionB) - } -} diff --git a/Tests/FeedbacksTests/StateMachine/TransitionsDefinitionTests.swift b/Tests/FeedbacksTests/StateMachine/TransitionsDefinitionTests.swift deleted file mode 100644 index 97384b8..0000000 --- a/Tests/FeedbacksTests/StateMachine/TransitionsDefinitionTests.swift +++ /dev/null @@ -1,50 +0,0 @@ -// -// TransitionsDefinitionTests.swift -// -// -// Created by Thibault Wittemberg on 2020-12-26. -// - -import Feedbacks -import XCTest - -private struct MockState: State, Equatable { let value: Int } -private struct AnotherMockState: State, Equatable { let value: Int } - -private struct MockEvent: Event {} -private struct AnotherMockEvent: Event {} - -final class TransitionsDefinitionTests: XCTestCase { - func testBuildBlock_build_array_of_transitionsDefinitions_with_variadicParameterOfTransitions() { - // Given: 2 transitions - let transitionA = Transition(from: MockState.self, on: MockEvent.self, then: MockState(value: 1)) - let transitionB = Transition(from: AnotherMockState.self, on: AnotherMockEvent.self, then: MockState(value: 2)) - - // When: making an array of transitions thanks to the ResultBuilder - let receivedTransitions = TransitionsDefinitionsBuilder.buildBlock(transitionA, transitionB) - - // Then: the array of transitions is composed of the input transitions - let receivedTransitionIds = receivedTransitions.flatMap { $0.entries.map { $0.key } } - let expectedTransitionsIds = ([transitionA, transitionB] as [TransitionsDefinition]).flatMap { $0.entries.map { $0.key } } - XCTAssertEqual(receivedTransitionIds, expectedTransitionsIds) - } - - func testBuildBlock_build_transitions_with_variadicParameterOfTransitions() { - // Given: 2 transitions - let transitionA = Transition(from: MockState.self, on: MockEvent.self, then: MockState(value: 1)) - let transitionB = Transition(from: AnotherMockState.self, on: AnotherMockEvent.self, then: MockState(value: 2)) - - // When: making transitions thanks to the ResultBuilder - let receivedTransitions = TransitionsBuilder.buildBlock(transitionA, transitionB) - - // Then: the transitions is composed of the input transitions - let receivedTransitionsIds = receivedTransitions.entries.map { $0.key }.sorted { (lhs, rhs) -> Bool in - lhs.hashValue < rhs.hashValue - } - let expectedTransitionsIds = ([transitionA, transitionB] as [TransitionsDefinition]).flatMap { $0.entries.map { $0.key } }.sorted { (lhs, rhs) -> Bool in - lhs.hashValue < rhs.hashValue - } - - XCTAssertEqual(receivedTransitionsIds, expectedTransitionsIds) - } -} diff --git a/Tests/FeedbacksTests/StateMachine/TransitionsTests.swift b/Tests/FeedbacksTests/StateMachine/TransitionsTests.swift index 1fa040d..1d2732d 100644 --- a/Tests/FeedbacksTests/StateMachine/TransitionsTests.swift +++ b/Tests/FeedbacksTests/StateMachine/TransitionsTests.swift @@ -1,156 +1,126 @@ // // TransitionsTests.swift -// +// // // Created by Thibault Wittemberg on 2020-12-24. // import Feedbacks +import FeedbacksTest import XCTest private struct MockState: State, Equatable { let value: Int } private struct AnotherMockState: State, Equatable { let value: Int } -private struct MockEvent: Event {} +private struct MockEvent: Event, Equatable { let value: Int } private struct AnotherMockEvent: Event {} final class TransitionsTests: XCTestCase { - func testEntries_merge_entries_from_composing_transitions() { - // Given: some transitions - let transitionA = Transition(from: MockState.self, on: MockEvent.self, then: MockState(value: 1)) - let transitionB = Transition(from: AnotherMockState.self, on: AnotherMockEvent.self, then: MockState(value: 2)) - - let sut = Transitions { - transitionA - transitionB - } - - // When: getting the transitions entries - let expectedEntries = transitionA.entries.merging(transitionB.entries, uniquingKeysWith: { $1 }) - let receivedEntries = sut.entries - - // Then: they are equal to the merging of its composing transitions - XCTAssertEqual(receivedEntries.keys, expectedEntries.keys) - } - - func testEntries_merge_entries_from_composing_transitions_using_the_second_transition_when_ids_are_equal() { - // Given: 2 transitions having the same ids - let transitionA = Transition(from: MockState.self, on: MockEvent.self, then: MockState(value: 1)) - let transitionB = Transition(from: MockState.self, on: MockEvent.self, then: MockState(value: 1701)) - + func testReducer_handles_the_combination_of_registered_and_not_registered_states_and_events() { + // Given: some transitions that handles registered states and events and also any states and any events + let expectedState1 = MockState(value: 1) + let expectedState2 = MockState(value: 2) + let expectedState3 = MockState(value: 3) + let expectedState4 = MockState(value: 4) + let sut = Transitions { - transitionA - transitionB + From(MockState.self) { + On(MockEvent.self, transitionTo: expectedState1) + On(AnyEvent.self, transitionTo: expectedState2) + } + + From(AnyState.self) { + On(MockEvent.self, transitionTo: expectedState3) + On(AnyEvent.self, transitionTo: expectedState4) + } } - - // When: getting the transitions entries - let receivedEntries = sut.entries - - // Then: the entries have only one entry, which is the last declared transition (transitionB) - XCTAssertEqual(receivedEntries.count, 1) - - let transitionBId = TransitionId(stateId: MockState.id, eventId: MockEvent.id) - let receivedReducer = receivedEntries[transitionBId]! - let receivedState = receivedReducer(MockState(value: 1), MockEvent()) - - XCTAssertEqual(receivedState as? MockState, MockState(value: 1701)) + + // When: reducing all the combinations are registers / not registered states and events + // Then: the reducer handles all the cases with the expected priority + sut.assertThat(from: MockState(value: 1), on: MockEvent(value: 1), newStateIs: expectedState1) + sut.assertThat(from: MockState(value: 1), on: AnotherMockEvent(), newStateIs: expectedState2) + sut.assertThat(from: AnotherMockState(value: 2), on: MockEvent(value: 1), newStateIs: expectedState3) + sut.assertThat(from: AnotherMockState(value: 2), on: AnotherMockEvent(), newStateIs: expectedState4) } - - func testReducer_handle_the_combination_of_registered_and_not_registered_states_and_events() { - // Given: some transitions that handles registered states and events and also any states and any events - - let mockTransitions = Transitions { - Transition(from: MockState.self, on: MockEvent.self, then: MockState(value: 1)) - Transition(from: MockState.self, on: AnyEvent.self, then: MockState(value: 2)) - } - - let anyTransitions = Transitions { - Transition(from: AnyState.self, on: MockEvent.self, then: MockState(value: 3)) - Transition(from: AnyState.self, on: AnyEvent.self, then: MockState(value: 4)) - } - + + func testReducer_is_given_the_expected_parameters() { + let expectedState = MockState(value: Int.random(in: 1...1_000_000)) + var receivedState: MockState? + + let expectedEvent = MockEvent(value: Int.random(in: 1...1_000_000)) + var receivedEvent: MockEvent? + + // Given: a transition that records its inputs let sut = Transitions { - mockTransitions - anyTransitions + From(MockState.self) { state in + On(MockEvent.self) { event in + receivedState = state + receivedEvent = event + return MockState(value: 1) + } + } } - - // When: getting its reducer and giving it all the combinations are registers / not registered states and events - let receivedReducer = sut.reducer - - let receivedStateWhenStateAndEventAreExplicitlyRegistered = receivedReducer(MockState(value: 1), MockEvent()) - let receivedStateWhenStateIsExplicitlyRegisteredAndEventIsNot = receivedReducer(MockState(value: 1), AnotherMockEvent()) - let receivedStateWhenStateIsNotExplicitlyRegisteredAndEventIs = receivedReducer(AnotherMockState(value: 2), MockEvent()) - let receivedStateWhenStateAndEventAreNotExplicitlyRegistered = receivedReducer(AnotherMockState(value: 2), AnotherMockEvent()) - - // Then: the reducer handles all the cases with the expected priority - XCTAssertEqual(receivedStateWhenStateAndEventAreExplicitlyRegistered as? MockState, MockState(value: 1)) - XCTAssertEqual(receivedStateWhenStateIsExplicitlyRegisteredAndEventIsNot as? MockState, MockState(value: 2)) - XCTAssertEqual(receivedStateWhenStateIsNotExplicitlyRegisteredAndEventIs as? MockState, MockState(value: 3)) - XCTAssertEqual(receivedStateWhenStateAndEventAreNotExplicitlyRegistered as? MockState, MockState(value: 4)) + + // When: executing the underlying reducer + _ = sut.reducer(expectedState, expectedEvent) + + // Then: the reducer receives the expected inputs + XCTAssertEqual(receivedState, expectedState) + XCTAssertEqual(receivedEvent, expectedEvent) } - - func testReducer_handle_the_combination_of_registered_and_not_registered_states_and_events2() { - // Given: some transitions that only handles registered states and events (no any states and any events) + + func testReducer_merge_transitionsForStates_when_same_stateId() { + let expectedState1 = MockState(value: Int.random(in: 1...1_000_000)) + let expectedState2 = MockState(value: Int.random(in: 1...1_000_000)) + + // Given: a state machine declaring 2 distinct transitions for MockState let sut = Transitions { - Transition(from: MockState.self, on: MockEvent.self, then: MockState(value: 1)) + From(MockState.self) { + On(MockEvent.self, transitionTo: expectedState1) + } + From(MockState.self) { + On(AnotherMockEvent.self, transitionTo: expectedState2) + } } - - // When: getting its reducer and giving it unregistered state and event - let receivedReducer = sut.reducer - - let expectedState = AnotherMockState(value: 1701) - - let receivedStateWhenStateAndEventAreNotExplicitlyRegistered = receivedReducer(expectedState, AnotherMockEvent()) - - // Then: the reducer returns the input state as a new state - XCTAssertEqual(receivedStateWhenStateAndEventAreNotExplicitlyRegistered as? AnotherMockState, expectedState) + + // When: executing the reducer with the same MockState but 2 different Events + // Then: both transitions are executed + sut.assertThat(from: MockState(value: 1), on: MockEvent(value: 1), newStateIs: expectedState1) + sut.assertThat(from: MockState(value: 1), on: AnotherMockEvent(), newStateIs: expectedState2) } - + func testDisable_dynamically_disable_the_transitions() { var isDisabled = true - + // Given: some transitions that is disabled when isDisabled is true let sut = Transitions { - Transition(from: MockState.self, on: MockEvent.self, then: AnotherMockState(value: 1)) - Transition(from: AnotherMockState.self, on: AnotherMockEvent.self, then: MockState(value: 1)) + From(MockState.self) { + On(MockEvent.self, transitionTo: AnotherMockState(value: 1)) + } + + From(AnotherMockState.self) { + On(AnotherMockEvent.self, transitionTo: MockState(value: 1)) + } } .disable { isDisabled } - - let reducer = sut.reducer - + // When: executing its associated reducer when isDisabled is true - let inputMockState1 = MockState(value: Int.random(in: 0...1_000_000)) - let newMockState1 = reducer(inputMockState1, MockEvent()) - // Then: the newMockState1 is the one from the input - XCTAssertEqual(newMockState1 as? MockState, inputMockState1) - - let inputAnotherMockState1 = AnotherMockState(value: Int.random(in: 0...1_000_000)) - let newAnotherMockState1 = reducer(inputAnotherMockState1, AnotherMockEvent()) - // Then: the newAnotherMockState1 is the one from the input - XCTAssertEqual(newAnotherMockState1 as? AnotherMockState, inputAnotherMockState1) - + // Then: the state machine returns the input state + sut.assertThatStateIsUnchanged(from: MockState(value: Int.random(in: 0...1_000_000)), on: MockEvent(value: 1)) + sut.assertThatStateIsUnchanged(from: AnotherMockState(value: Int.random(in: 0...1_000_000)), on: AnotherMockEvent()) + isDisabled = false - + // When: executing its associated reducer when isDisabled is false - let newMockState2 = reducer(MockState(value: 1), MockEvent()) - // Then: the newMockState2 is the one declared in the transition - XCTAssertEqual(newMockState2 as? AnotherMockState, AnotherMockState(value: 1)) - - let newAnotherMockState2 = reducer(AnotherMockState(value: 2), AnotherMockEvent()) - // Then: the newAnotherMockState2 is the one from the input - XCTAssertEqual(newAnotherMockState2 as? MockState, MockState(value: 1)) - + // Then: the state machine computes expected new states + sut.assertThat(from: MockState(value: 1), on: MockEvent(value: 1), newStateIs: AnotherMockState(value: 1)) + sut.assertThat(from: AnotherMockState(value: 2), on: AnotherMockEvent(), newStateIs: MockState(value: 1)) + isDisabled = true - + // When: executing its associated reducer when isDisabled is true - let inputMockState3 = MockState(value: Int.random(in: 0...1_000_000)) - let newMockState3 = reducer(inputMockState3, MockEvent()) - // Then: the newMockState3 is the one from the input - XCTAssertEqual(newMockState3 as? MockState, inputMockState3) - - let inputAnotherMockState3 = AnotherMockState(value: Int.random(in: 0...1_000_000)) - let newAnotherMockState3 = reducer(inputAnotherMockState3, AnotherMockEvent()) - // Then: the newAnotherMockState3 is the one from the input - XCTAssertEqual(newAnotherMockState3 as? AnotherMockState, inputAnotherMockState3) + // Then: the state machine returns the input state + sut.assertThatStateIsUnchanged(from: MockState(value: Int.random(in: 0...1_000_000)), on: MockEvent(value: 1)) + sut.assertThatStateIsUnchanged(from: AnotherMockState(value: Int.random(in: 0...1_000_000)), on: AnotherMockEvent()) } } diff --git a/Tests/FeedbacksTests/System/FeedbackTests.swift b/Tests/FeedbacksTests/System/FeedbackTests.swift index 9c451c9..8e9b93c 100644 --- a/Tests/FeedbacksTests/System/FeedbackTests.swift +++ b/Tests/FeedbacksTests/System/FeedbackTests.swift @@ -216,7 +216,7 @@ extension FeedbackTests { } // When: making a Feedback of it, and executing it on the expected Queue - let sut = Feedback(on: MockStateA.self, strategy: .continueOnNewState, sideEffect: spySideEffect) + let sut = Feedback(on: MockStateA.self, strategy: .continueOnNewState, perform: spySideEffect) .execute(on: DispatchQueue(label: expectedQueue)) let cancellable = sut.sideEffect(Just(MockStateA(value: 1)).eraseToAnyPublisher()).sink{ _ in exp.fulfill() } @@ -244,7 +244,7 @@ extension FeedbackTests { } // When: making a feedback of it, with a disable(:) modifier - let sut = Feedback(on: AnyState.self, strategy: .continueOnNewState, sideEffect: spySideEffect).disable { isDisabled } + let sut = Feedback(on: AnyState.self, strategy: .continueOnNewState, perform: spySideEffect).disable { isDisabled } let inputStateStream = PassthroughSubject() diff --git a/Tests/FeedbacksTests/StateMachine/SideEffectTests.swift b/Tests/FeedbacksTests/System/SideEffectTests.swift similarity index 100% rename from Tests/FeedbacksTests/StateMachine/SideEffectTests.swift rename to Tests/FeedbacksTests/System/SideEffectTests.swift diff --git a/Tests/FeedbacksTests/System/SystemTests.swift b/Tests/FeedbacksTests/System/SystemTests.swift index 629bfb2..aee160a 100644 --- a/Tests/FeedbacksTests/System/SystemTests.swift +++ b/Tests/FeedbacksTests/System/SystemTests.swift @@ -39,7 +39,11 @@ final class SystemTests: XCTestCase { } Transitions { - Transition(from: AnyState.self, on: AnyEvent.self, then: { _, _ in MockStateB(value: 1) }) + From(AnyState.self) { _ in + On(AnyEvent.self) { _ in + MockStateB(value: 1) + } + } } } @@ -90,14 +94,18 @@ final class SystemTests: XCTestCase { } Transitions { - Transition(from: MockStateA.self, on: MockNextEvent.self) { state, _ -> State in - receivedSystemQueue.append(DispatchQueue.currentLabel) - return MockStateB(value: state.value) + From(MockStateA.self) { state in + On(MockNextEvent.self) { _ in + receivedSystemQueue.append(DispatchQueue.currentLabel) + return MockStateB(value: state.value) + } } - Transition(from: MockStateB.self, on: MockNextEvent.self) { state, _ -> State in - receivedSystemQueue.append(DispatchQueue.currentLabel) - return MockStateA(value: state.value + 1) + From(MockStateB.self) { state in + On(MockNextEvent.self) { _ in + receivedSystemQueue.append(DispatchQueue.currentLabel) + return MockStateA(value: state.value + 1) + } } } } @@ -145,14 +153,18 @@ final class SystemTests: XCTestCase { } Transitions { - Transition(from: MockStateA.self, on: MockNextEvent.self) { state, _ -> State in - receivedSystemQueue.append(DispatchQueue.currentLabel) - return MockStateB(value: state.value) + From(MockStateA.self) { state in + On(MockNextEvent.self) { _ in + receivedSystemQueue.append(DispatchQueue.currentLabel) + return MockStateB(value: state.value) + } } - Transition(from: MockStateB.self, on: MockNextEvent.self) { state, _ -> State in - receivedSystemQueue.append(DispatchQueue.currentLabel) - return MockStateA(value: state.value + 1) + From(MockStateB.self) { state in + On(MockNextEvent.self) { _ in + receivedSystemQueue.append(DispatchQueue.currentLabel) + return MockStateA(value: state.value + 1) + } } } } @@ -225,20 +237,20 @@ final class SystemTests: XCTestCase { } Transitions { - Transition(from: MockStateA.self, on: MockNextEvent.self) { state, _ -> State in - return MockStateB(value: state.value) + From(MockStateA.self) { state in + On(MockNextEvent.self) { _ in MockStateB(value: state.value) } } - Transition(from: MockStateB.self, on: MockNextEvent.self) { state, _ -> State in - return MockStateC(value: state.value) + From(MockStateB.self) { state in + On(MockNextEvent.self) { _ in MockStateC(value: state.value) } } - Transition(from: MockStateC.self, on: MockNextEvent.self) { state, _ -> State in - return MockStateD(value: state.value) + From(MockStateC.self) { state in + On(MockNextEvent.self) { _ in MockStateD(value: state.value) } } - Transition(from: MockStateD.self, on: MockNextEvent.self) { state, _ -> State in - return MockStateA(value: state.value + 1) + From(MockStateD.self) { state in + On(MockNextEvent.self) { _ in MockStateA(value: state.value + 1) } } } }.execute(on: DispatchQueue(label: UUID().uuidString)) @@ -277,7 +289,9 @@ final class SystemTests: XCTestCase { } Transitions { - Transition(from: MockStateA.self, on: MockNextEvent.self, then: MockStateB(value: 2)) + From(MockStateA.self) { _ in + On(MockNextEvent.self) { _ in MockStateB(value: 2) } + } } } .execute(on: DispatchQueue.immediateScheduler) @@ -308,7 +322,9 @@ final class SystemTests: XCTestCase { } Transitions { - Transition(from: MockStateA.self, on: MockNextEvent.self, then: MockStateB(value: 2)) + From(MockStateA.self) { + On(MockNextEvent.self, transitionTo: MockStateB(value: 2)) + } } } .attach(to: mediator, emitSystemEvent: { $0 == 1701 ? expectedEvent : nil }) @@ -352,7 +368,9 @@ final class SystemTests: XCTestCase { } Transitions { - Transition(from: MockStateA.self, on: MockNextEvent.self, then: MockStateB(value: 2)) + From(MockStateA.self) { _ in + On(MockNextEvent.self) { _ in MockStateB(value: 2) } + } } } .attach(to: mediator, onMediatorValue: 1701 , emitSystemEvent: { _ in expectedEvent }) @@ -396,7 +414,9 @@ final class SystemTests: XCTestCase { } Transitions { - Transition(from: MockStateA.self, on: MockNextEvent.self, then: MockStateB(value: 2)) + From(MockStateA.self) { _ in + On(MockNextEvent.self) { _ in MockStateB(value: 2) } + } } } .attach(to: mediator, onMediatorValue: 1701 , emitSystemEvent: expectedEvent) @@ -439,7 +459,9 @@ final class SystemTests: XCTestCase { } Transitions { - Transition(from: MockStateA.self, on: MockNextEvent.self, then: MockStateB(value: 2)) + From(MockStateA.self) { _ in + On(MockNextEvent.self) { _ in MockStateB(value: 2) } + } } } .attach(to: mediator, @@ -490,7 +512,9 @@ final class SystemTests: XCTestCase { } Transitions { - Transition(from: MockStateA.self, on: MockNextEvent.self, then: MockStateB(value: 2)) + From(MockStateA.self) { _ in + On(MockNextEvent.self) { _ in MockStateB(value: 2) } + } } } .attach(to: mediator, @@ -542,7 +566,9 @@ final class SystemTests: XCTestCase { } Transitions { - Transition(from: MockStateA.self, on: MockNextEvent.self, then: MockStateB(value: 2)) + From(MockStateA.self) { _ in + On(MockNextEvent.self) { _ in MockStateB(value: 2) } + } } } .attach(to: mediator, @@ -594,7 +620,9 @@ final class SystemTests: XCTestCase { } Transitions { - Transition(from: MockStateA.self, on: MockNextEvent.self, then: MockStateB(value: 2)) + From(MockStateA.self) { + On(MockNextEvent.self, transitionTo: MockStateB(value: 2)) + } } } .attach(to: mediator, @@ -646,7 +674,9 @@ final class SystemTests: XCTestCase { } Transitions { - Transition(from: MockStateA.self, on: MockNextEvent.self, then: MockStateB(value: 2)) + From(MockStateA.self) { + On(MockNextEvent.self, transitionTo: MockStateB(value: 2)) + } } } .attach(to: mediator, @@ -703,7 +733,9 @@ final class SystemTests: XCTestCase { } Transitions { - Transition(from: MockStateA.self, on: MockNextEvent.self, then: MockStateB(value: randomValue)) + From(MockStateA.self) { + On(MockNextEvent.self, transitionTo: MockStateB(value: randomValue)) + } } } .execute(on: DispatchQueue.immediateScheduler) diff --git a/Tests/FeedbacksTests/System/UISystemTests.swift b/Tests/FeedbacksTests/System/UISystemTests.swift index 0ef79d8..2c20d71 100644 --- a/Tests/FeedbacksTests/System/UISystemTests.swift +++ b/Tests/FeedbacksTests/System/UISystemTests.swift @@ -29,7 +29,9 @@ final class UISystemTests: XCTestCase { Feedbacks {} Transitions { - Transition(from: MockState.self, on: MockEvent.self, then: MockState(value: 10)) + From(MockState.self) { _ in + On(MockEvent.self) { _ in MockState(value: 10) } + } } } @@ -104,7 +106,9 @@ final class UISystemTests: XCTestCase { Feedbacks {} Transitions { - Transition(from: MockState.self, on: MockEvent.self, then: mutatedState) + From(MockState.self) { _ in + On(MockEvent.self) { _ in mutatedState } + } } } .execute(on: DispatchQueue.immediateScheduler) @@ -142,9 +146,11 @@ final class UISystemTests: XCTestCase { } Transitions { - Transition(from: MockState.self, on: MockEvent.self) { _, _ in - receivedQueue = DispatchQueue.currentLabel - return MockState(value: 1) + From(MockState.self) { _ in + On(MockEvent.self) { _ in + receivedQueue = DispatchQueue.currentLabel + return MockState(value: 1) + } } } } @@ -169,7 +175,9 @@ final class UISystemTests: XCTestCase { Feedbacks { } Transitions { - Transition(from: MockState.self, on: MockEvent.self, then: MockState(value: 10)) + From(MockState.self) { _ in + On(MockEvent.self) { _ in MockState(value: 10) } + } } } @@ -333,7 +341,9 @@ final class UISystemTests: XCTestCase { Feedbacks {} Transitions { - Transition(from: MockState.self, on: MockEvent.self, then: mutatedState) + From(MockState.self) { _ in + On(MockEvent.self) { _ in mutatedState } + } } } .execute(on: DispatchQueue.immediateScheduler) @@ -376,9 +386,11 @@ final class UISystemTests: XCTestCase { } Transitions { - Transition(from: MockState.self, on: MockEvent.self) { _, _ in - receivedQueue = DispatchQueue.currentLabel - return MockState(value: 1) + From(MockState.self) { _ in + On(MockEvent.self) { _ in + receivedQueue = DispatchQueue.currentLabel + return MockState(value: 1) + } } } } @@ -432,7 +444,9 @@ final class UISystemTests: XCTestCase { InitialState { MockState(value: 1) } Feedbacks {} Transitions { - Transition(from: MockState.self, on: MockEvent.self, then: MockState(value: 1)) + From(MockState.self) { _ in + On(MockEvent.self) { _ in MockState(value: 1) } + } } } .execute(on: DispatchQueue.immediateScheduler)