diff --git a/TestableDesignExample.xcodeproj/project.pbxproj b/TestableDesignExample.xcodeproj/project.pbxproj index 16ee880..ee70a23 100644 --- a/TestableDesignExample.xcodeproj/project.pbxproj +++ b/TestableDesignExample.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ 2475E17C0E6D441728D39ECA /* StargazerRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2475E2D3B67D9970FD6B94BF /* StargazerRepository.swift */; }; 2475E184C491ECB5D4520C4D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 2475EDAB59CE5A5E99A03E61 /* Assets.xcassets */; }; 2475E1DF0A8DCE28B24B836E /* RootNavigator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2475EF5D1BAB7F11EA4C887D /* RootNavigator.swift */; }; + 2475E1F7383D96C8179860E9 /* PageEndDetectionStrategyStub.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2475E3FDADF2B0C6C13CE5E1 /* PageEndDetectionStrategyStub.swift */; }; 2475E21443951FE6C9E9CC0B /* StargazersModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2475EC7516DF00CF3095186A /* StargazersModelTests.swift */; }; 2475E296D4A45CC8EF30F8E8 /* GitHubApiClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2475E9F9C6BB7D41F55161A7 /* GitHubApiClient.swift */; }; 2475E2AC84566117FC687FC8 /* JsonReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2475E3FCF9976BD662C10237 /* JsonReader.swift */; }; @@ -22,21 +23,30 @@ 2475E3B1BAEAEE1D8FD372DC /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2475E00BE716C34F1495A4DE /* AppDelegate.swift */; }; 2475E40D232C0867EE3CBDD0 /* NavigatorStub.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2475EF9C786B34F42182126A /* NavigatorStub.swift */; }; 2475E427AF93298C1BA03EB1 /* StargazersControllerMediator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2475E892E746679C895D57CC /* StargazersControllerMediator.swift */; }; + 2475E437BCE2D828E2DE8608 /* PagedElementCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2475EE00EDD7E12FE4F3158F /* PagedElementCollection.swift */; }; + 2475E4644B84BA23207DBA28 /* PagingModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2475E9701455B0421AAF96DF /* PagingModelTests.swift */; }; 2475E4F5BAA39082F5129854 /* GitHubUser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2475E96540D7FDFD907E38A1 /* GitHubUser.swift */; }; + 2475E52AFF57CA80947B35B8 /* PageEndDetectionStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2475EA6CE7CCD06FE9AAE392 /* PageEndDetectionStrategy.swift */; }; 2475E578041452F8670C5A8C /* UserMvcComposerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2475EDEF357B21E401A94659 /* UserMvcComposerTests.swift */; }; + 2475E5EEB2DAE071F555A468 /* PagingModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2475EE1B5C297CBB2DB792F7 /* PagingModel.swift */; }; + 2475E63DC2AE2BBCED7A5D62 /* PagingCursorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2475E7ECB9C6033E2222F3D9 /* PagingCursorTests.swift */; }; 2475E797D65B825304504F9B /* FatalErrorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2475EB38628EF03DCAD17F84 /* FatalErrorViewController.swift */; }; 2475E7A6273DD9CBF4ABA5BA /* AnyError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2475E8757F392A3269C299DC /* AnyError.swift */; }; 2475E81254A76DFDA50D9D83 /* StargazersViewMediator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2475E0C468760C2DAE1C894B /* StargazersViewMediator.swift */; }; 2475E8169213EC2CBA0BA1B8 /* R.generated.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2475E168B20E5C501400CF73 /* R.generated.swift */; }; 2475E87E0BE147383E77BB45 /* BootstrapViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2475E06E3DEED46F70D0B0AF /* BootstrapViewController.swift */; }; + 2475E8B006035467EAB8CA51 /* PageEndDirection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2475E026EF95F2C30727924A /* PageEndDirection.swift */; }; 2475E8D08D795709933F77E2 /* FontRegistryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2475E99206AD0AA53E23C74F /* FontRegistryTests.swift */; }; 2475E8D6EE86B076D4CB3361 /* RemoteImageSourceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2475E2939E6563048AA378B3 /* RemoteImageSourceTests.swift */; }; 2475E9807E8143A4B3A751A5 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 2475E33610A7BBD163657C40 /* LaunchScreen.storyboard */; }; 2475E9C5968FE2FDD131A353 /* WebPageOpener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2475E9BB7111AF583D00867E /* WebPageOpener.swift */; }; 2475EA2BD6E67ECEAE176195 /* RemoteImageSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2475E767B5CB5B7E0C0B0A6E /* RemoteImageSource.swift */; }; + 2475EA44D9638AEDA24CE1DC /* PageRepositorySpy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2475E29FA1DBCF20C012D776 /* PageRepositorySpy.swift */; }; 2475EB37E60B9FA89BC83FF4 /* GitHubApiClientStub.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2475EAAA03A6CC33D5660297 /* GitHubApiClientStub.swift */; }; + 2475EBBEE0CB66156780EAC1 /* PageRepositoryStub.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2475EBC20C528C65C22AC76E /* PageRepositoryStub.swift */; }; 2475EBC38C427D41A452E006 /* GitHubStargazer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2475EA9AAFF3FDC512301D94 /* GitHubStargazer.swift */; }; 2475EBE734270642753E85C1 /* StargazerRepositoryStub.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2475E49AC1AFD52DCC81BE25 /* StargazerRepositoryStub.swift */; }; + 2475EC3DE8C8F6480DDCD136 /* PagingCursor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2475E557348E1E9BB7330B3A /* PagingCursor.swift */; }; 2475ED052F280DF12FCC21BF /* TestableDesignExampleUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2475E52EE895096067C3AB7E /* TestableDesignExampleUITests.swift */; }; 2475ED5BF2B685F9B76415C5 /* VisualDecorator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2475E26107E142D6CF5AAC43 /* VisualDecorator.swift */; }; 2475ED6B21CB5EEF515D37D8 /* GitHubApiEndpointTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2475E6DFB3857B78D5216317 /* GitHubApiEndpointTests.swift */; }; @@ -46,6 +56,7 @@ 2475EE26A0DFBEC4EB5BAE99 /* FontRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2475E633D1D5C9D0166E0EF1 /* FontRegistry.swift */; }; 2475EE30CCE8B8CC85956685 /* Async.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2475E4CF0A05D4A5ADE28575 /* Async.swift */; }; 2475EE86B1751D6D830B3BBD /* TestNavigator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2475E01DE6FDD5A3028F1B32 /* TestNavigator.swift */; }; + 2475EEB29A601F4F56E18789 /* PageRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2475EF074C2A57218C33A52B /* PageRepository.swift */; }; 2475EEC0350AE44691DDC0BA /* GitHubApiClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2475E0A01A4162606F18EA04 /* GitHubApiClientTests.swift */; }; 2475EEE96C335A9F12A6CDCB /* Lifter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2475E04E8578411420D30C66 /* Lifter.swift */; }; 2475EF0E319C2B3CFC48C762 /* UserMvcComposer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2475E6CC4A27B8058F8CAE7F /* UserMvcComposer.swift */; }; @@ -91,6 +102,7 @@ /* Begin PBXFileReference section */ 2475E00BE716C34F1495A4DE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 2475E01DE6FDD5A3028F1B32 /* TestNavigator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestNavigator.swift; sourceTree = ""; }; + 2475E026EF95F2C30727924A /* PageEndDirection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PageEndDirection.swift; sourceTree = ""; }; 2475E04E8578411420D30C66 /* Lifter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Lifter.swift; sourceTree = ""; }; 2475E06E3DEED46F70D0B0AF /* BootstrapViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BootstrapViewController.swift; sourceTree = ""; }; 2475E0A01A4162606F18EA04 /* GitHubApiClientTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GitHubApiClientTests.swift; sourceTree = ""; }; @@ -101,12 +113,15 @@ 2475E1A4E2B89145A865C025 /* Navigator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Navigator.swift; sourceTree = ""; }; 2475E26107E142D6CF5AAC43 /* VisualDecorator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VisualDecorator.swift; sourceTree = ""; }; 2475E2939E6563048AA378B3 /* RemoteImageSourceTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteImageSourceTests.swift; sourceTree = ""; }; + 2475E29FA1DBCF20C012D776 /* PageRepositorySpy.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PageRepositorySpy.swift; sourceTree = ""; }; 2475E2D3B67D9970FD6B94BF /* StargazerRepository.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StargazerRepository.swift; sourceTree = ""; }; 2475E3FCF9976BD662C10237 /* JsonReader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = JsonReader.swift; path = TestableDesignExampleTests/Resources/JsonReader.swift; sourceTree = SOURCE_ROOT; }; + 2475E3FDADF2B0C6C13CE5E1 /* PageEndDetectionStrategyStub.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PageEndDetectionStrategyStub.swift; sourceTree = ""; }; 2475E49AC1AFD52DCC81BE25 /* StargazerRepositoryStub.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StargazerRepositoryStub.swift; sourceTree = ""; }; 2475E4CF0A05D4A5ADE28575 /* Async.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Async.swift; sourceTree = ""; }; 2475E52EE895096067C3AB7E /* TestableDesignExampleUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestableDesignExampleUITests.swift; sourceTree = ""; }; 2475E53EEBB0B2F0A445AAC9 /* StargazerMvcComposerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StargazerMvcComposerTests.swift; sourceTree = ""; }; + 2475E557348E1E9BB7330B3A /* PagingCursor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PagingCursor.swift; sourceTree = ""; }; 2475E5AD62D5E53E91973FEB /* R.generatedTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = R.generatedTests.swift; sourceTree = ""; }; 2475E5B4D098FE6945B3F148 /* sample.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = sample.png; sourceTree = ""; }; 2475E633D1D5C9D0166E0EF1 /* FontRegistry.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FontRegistry.swift; sourceTree = ""; }; @@ -116,17 +131,21 @@ 2475E6DFB3857B78D5216317 /* GitHubApiEndpointTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GitHubApiEndpointTests.swift; sourceTree = ""; }; 2475E767B5CB5B7E0C0B0A6E /* RemoteImageSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteImageSource.swift; sourceTree = ""; }; 2475E7D9EF9A0EC0145E0708 /* TestableDesignExampleTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestableDesignExampleTests.swift; sourceTree = ""; }; + 2475E7ECB9C6033E2222F3D9 /* PagingCursorTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PagingCursorTests.swift; sourceTree = ""; }; 2475E8757F392A3269C299DC /* AnyError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnyError.swift; sourceTree = ""; }; 2475E892E746679C895D57CC /* StargazersControllerMediator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StargazersControllerMediator.swift; sourceTree = ""; }; 2475E96540D7FDFD907E38A1 /* GitHubUser.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GitHubUser.swift; sourceTree = ""; }; + 2475E9701455B0421AAF96DF /* PagingModelTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PagingModelTests.swift; sourceTree = ""; }; 2475E99206AD0AA53E23C74F /* FontRegistryTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FontRegistryTests.swift; sourceTree = ""; }; 2475E9BB7111AF583D00867E /* WebPageOpener.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WebPageOpener.swift; sourceTree = ""; }; 2475E9C17E4BBC136112F1C3 /* GitHubRepository.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GitHubRepository.swift; sourceTree = ""; }; 2475E9F9C6BB7D41F55161A7 /* GitHubApiClient.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GitHubApiClient.swift; sourceTree = ""; }; + 2475EA6CE7CCD06FE9AAE392 /* PageEndDetectionStrategy.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PageEndDetectionStrategy.swift; sourceTree = ""; }; 2475EA8A1CD58D89A6FC8E3E /* StargazersModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StargazersModel.swift; sourceTree = ""; }; 2475EA9AAFF3FDC512301D94 /* GitHubStargazer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GitHubStargazer.swift; sourceTree = ""; }; 2475EAAA03A6CC33D5660297 /* GitHubApiClientStub.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GitHubApiClientStub.swift; sourceTree = ""; }; 2475EB38628EF03DCAD17F84 /* FatalErrorViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FatalErrorViewController.swift; sourceTree = ""; }; + 2475EBC20C528C65C22AC76E /* PageRepositoryStub.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PageRepositoryStub.swift; sourceTree = ""; }; 2475EC3A6667F8553C23CB2C /* GitHubApiEndpoint.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GitHubApiEndpoint.swift; sourceTree = ""; }; 2475EC4757610B2EC6E83A3A /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 2475EC7516DF00CF3095186A /* StargazersModelTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StargazersModelTests.swift; sourceTree = ""; }; @@ -136,8 +155,11 @@ 2475ED639ECF55577329F9D9 /* TestableDesignExampleUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TestableDesignExampleUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 2475EDAB59CE5A5E99A03E61 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 2475EDEF357B21E401A94659 /* UserMvcComposerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserMvcComposerTests.swift; sourceTree = ""; }; + 2475EE00EDD7E12FE4F3158F /* PagedElementCollection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PagedElementCollection.swift; sourceTree = ""; }; + 2475EE1B5C297CBB2DB792F7 /* PagingModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PagingModel.swift; sourceTree = ""; }; 2475EE7E92AA265C15A8AB02 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.info; path = Info.plist; sourceTree = ""; }; 2475EE88C83579435E9F67BA /* ColorCatalog.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ColorCatalog.swift; sourceTree = ""; }; + 2475EF074C2A57218C33A52B /* PageRepository.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PageRepository.swift; sourceTree = ""; }; 2475EF5D1BAB7F11EA4C887D /* RootNavigator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RootNavigator.swift; sourceTree = ""; }; 2475EF639EA034BCC0C26512 /* octicons.ttf */ = {isa = PBXFileReference; lastKnownFileType = file.ttf; name = octicons.ttf; path = Resources/octicons.ttf; sourceTree = ""; }; 2475EF9C786B34F42182126A /* NavigatorStub.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NavigatorStub.swift; sourceTree = ""; }; @@ -217,6 +239,24 @@ path = Stargazers; sourceTree = ""; }; + 2475E087ABE573EBFD40B60E /* Paging */ = { + isa = PBXGroup; + children = ( + 2475EE1B5C297CBB2DB792F7 /* PagingModel.swift */, + 2475E026EF95F2C30727924A /* PageEndDirection.swift */, + 2475E557348E1E9BB7330B3A /* PagingCursor.swift */, + 2475E7ECB9C6033E2222F3D9 /* PagingCursorTests.swift */, + 2475EF074C2A57218C33A52B /* PageRepository.swift */, + 2475EE00EDD7E12FE4F3158F /* PagedElementCollection.swift */, + 2475EA6CE7CCD06FE9AAE392 /* PageEndDetectionStrategy.swift */, + 2475E9701455B0421AAF96DF /* PagingModelTests.swift */, + 2475EBC20C528C65C22AC76E /* PageRepositoryStub.swift */, + 2475E29FA1DBCF20C012D776 /* PageRepositorySpy.swift */, + 2475E3FDADF2B0C6C13CE5E1 /* PageEndDetectionStrategyStub.swift */, + ); + path = Paging; + sourceTree = ""; + }; 2475E2F3D72A9EDDB3A5123C /* TestableDesignExample */ = { isa = PBXGroup; children = ( @@ -276,6 +316,7 @@ 2475E03D39E9FDB714DC39B2 /* Stargazers */, 2475EAB52F50FA3341C7D551 /* FatalError */, 2475EA25575CDC3E6312B17A /* User */, + 2475E087ABE573EBFD40B60E /* Paging */, ); path = MvcArchitecture; sourceTree = ""; @@ -628,6 +669,12 @@ 2475E2F948F045B2F1CCAE48 /* GitHubApiEndpoint.swift in Sources */, 2475EE26A0DFBEC4EB5BAE99 /* FontRegistry.swift in Sources */, 2475EEE96C335A9F12A6CDCB /* Lifter.swift in Sources */, + 2475E8B006035467EAB8CA51 /* PageEndDirection.swift in Sources */, + 2475EC3DE8C8F6480DDCD136 /* PagingCursor.swift in Sources */, + 2475E437BCE2D828E2DE8608 /* PagedElementCollection.swift in Sources */, + 2475EEB29A601F4F56E18789 /* PageRepository.swift in Sources */, + 2475E52AFF57CA80947B35B8 /* PageEndDetectionStrategy.swift in Sources */, + 2475E5EEB2DAE071F555A468 /* PagingModel.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -653,6 +700,11 @@ 2475ED6B21CB5EEF515D37D8 /* GitHubApiEndpointTests.swift in Sources */, 2475EF8187459829B292AFA9 /* R.generatedTests.swift in Sources */, 2475E8D08D795709933F77E2 /* FontRegistryTests.swift in Sources */, + 2475E63DC2AE2BBCED7A5D62 /* PagingCursorTests.swift in Sources */, + 2475EA44D9638AEDA24CE1DC /* PageRepositorySpy.swift in Sources */, + 2475EBBEE0CB66156780EAC1 /* PageRepositoryStub.swift in Sources */, + 2475E4644B84BA23207DBA28 /* PagingModelTests.swift in Sources */, + 2475E1F7383D96C8179860E9 /* PageEndDetectionStrategyStub.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/TestableDesignExample/MvcArchitecture/Paging/PageEndDetectionStrategy.swift b/TestableDesignExample/MvcArchitecture/Paging/PageEndDetectionStrategy.swift new file mode 100644 index 0000000..ddf8b92 --- /dev/null +++ b/TestableDesignExample/MvcArchitecture/Paging/PageEndDetectionStrategy.swift @@ -0,0 +1,56 @@ +protocol PageEndDetectionStrategyContract { + associatedtype Element + + func isPageEnd( + fetching pageNumber: Int, + to direction: PageEndDirection, + storedCollection: [Element], + fetchedCollection: [Element] + ) -> Bool +} + + + +extension PageEndDetectionStrategyContract { + func asAny() -> AnyPageEndDetectionStrategy { + return AnyPageEndDetectionStrategy(wrapping: self) + } +} + + + +class AnyPageEndDetectionStrategy: PageEndDetectionStrategyContract { + typealias Element = T + + + private let _isPageEnd: (Int, PageEndDirection, [Element], [Element]) -> Bool + + + init( + wrapping wrappedStrategy: WrappedStrategy + ) where WrappedStrategy.Element == Element { + self._isPageEnd = { (pageNumber, direction, storedCollection, fetchedCollection) in + return wrappedStrategy.isPageEnd( + fetching: pageNumber, + to: direction, + storedCollection: storedCollection, + fetchedCollection: fetchedCollection + ) + } + } + + + func isPageEnd( + fetching pageNumber: Int, + to direction: PageEndDirection, + storedCollection: [Element], + fetchedCollection: [Element] + ) -> Bool { + return self._isPageEnd( + pageNumber, + direction, + storedCollection, + fetchedCollection + ) + } +} diff --git a/TestableDesignExample/MvcArchitecture/Paging/PageEndDetectionStrategyStub.swift b/TestableDesignExample/MvcArchitecture/Paging/PageEndDetectionStrategyStub.swift new file mode 100644 index 0000000..e0bd2d5 --- /dev/null +++ b/TestableDesignExample/MvcArchitecture/Paging/PageEndDetectionStrategyStub.swift @@ -0,0 +1,27 @@ +@testable import TestableDesignExample + + + +class PageEndDetectionStrategyStub: PageEndDetectionStrategyContract { + typealias Element = T + + + private let range: Range + + + init( + whereCursorMovingOn range: Range + ) { + self.range = range + } + + + func isPageEnd( + fetching pageNumber: Int, + to direction: PageEndDirection, + storedCollection: [Element], + fetchedCollection: [Element] + ) -> Bool { + return !(range ~= pageNumber) + } +} \ No newline at end of file diff --git a/TestableDesignExample/MvcArchitecture/Paging/PageEndDirection.swift b/TestableDesignExample/MvcArchitecture/Paging/PageEndDirection.swift new file mode 100644 index 0000000..2212652 --- /dev/null +++ b/TestableDesignExample/MvcArchitecture/Paging/PageEndDirection.swift @@ -0,0 +1,4 @@ +enum PageEndDirection { + case next + case previous +} diff --git a/TestableDesignExample/MvcArchitecture/Paging/PageRepository.swift b/TestableDesignExample/MvcArchitecture/Paging/PageRepository.swift new file mode 100644 index 0000000..1e83a86 --- /dev/null +++ b/TestableDesignExample/MvcArchitecture/Paging/PageRepository.swift @@ -0,0 +1,40 @@ +import PromiseKit + + + +protocol PageRepositoryContract { + associatedtype Element: Hashable + + func fetch(pageOf pageNumber: Int) -> Promise<[Element]> +} + + + +extension PageRepositoryContract { + func asAny() -> AnyPageRepository { + return AnyPageRepository(wrapping: self) + } +} + + + +class AnyPageRepository: PageRepositoryContract { + typealias Element = T + + + private let _fetch: (Int) -> Promise<[Element]> + + + init( + wrapping wrappedRepository: WrappedRepository + ) where WrappedRepository.Element == Element { + self._fetch = { pageNumber in + return wrappedRepository.fetch(pageOf: pageNumber) + } + } + + + func fetch(pageOf pageNumber: Int) -> Promise<[Element]> { + return self._fetch(pageNumber) + } +} diff --git a/TestableDesignExample/MvcArchitecture/Paging/PageRepositorySpy.swift b/TestableDesignExample/MvcArchitecture/Paging/PageRepositorySpy.swift new file mode 100644 index 0000000..7dac32c --- /dev/null +++ b/TestableDesignExample/MvcArchitecture/Paging/PageRepositorySpy.swift @@ -0,0 +1,15 @@ +import PromiseKit +@testable import TestableDesignExample + + + +class PageRepositorySpy: PageRepositoryStub { + private(set) var calledArguments = [Int]() + + + override func fetch(pageOf pageNumber: Int) -> Promise<[Element]> { + self.calledArguments.append(pageNumber) + + return super.fetch(pageOf: pageNumber) + } +} diff --git a/TestableDesignExample/MvcArchitecture/Paging/PageRepositoryStub.swift b/TestableDesignExample/MvcArchitecture/Paging/PageRepositoryStub.swift new file mode 100644 index 0000000..8bba5db --- /dev/null +++ b/TestableDesignExample/MvcArchitecture/Paging/PageRepositoryStub.swift @@ -0,0 +1,21 @@ +import PromiseKit +@testable import TestableDesignExample + + + +class PageRepositoryStub: PageRepositoryContract { + typealias Element = T + + + private let block: (Int) -> Promise<[Element]> + + + init(willReturn block: @escaping (Int) -> Promise<[Element]>) { + self.block = block + } + + + func fetch(pageOf pageNumber: Int) -> Promise<[Element]> { + return self.block(pageNumber) + } +} \ No newline at end of file diff --git a/TestableDesignExample/MvcArchitecture/Paging/PagedElementCollection.swift b/TestableDesignExample/MvcArchitecture/Paging/PagedElementCollection.swift new file mode 100644 index 0000000..39661aa --- /dev/null +++ b/TestableDesignExample/MvcArchitecture/Paging/PagedElementCollection.swift @@ -0,0 +1,16 @@ +enum PagedElementCollection { + static func append(_ storedElements: [Element], and fetchedElements: [Element]) -> [Element] { + var result = storedElements + let set = Set(storedElements) + + fetchedElements.forEach { element in + guard !set.contains(element) else { + return + } + + result.append(element) + } + + return result + } +} diff --git a/TestableDesignExample/MvcArchitecture/Paging/PagingCursor.swift b/TestableDesignExample/MvcArchitecture/Paging/PagingCursor.swift new file mode 100644 index 0000000..1cfa81a --- /dev/null +++ b/TestableDesignExample/MvcArchitecture/Paging/PagingCursor.swift @@ -0,0 +1,85 @@ +protocol PagingCursorContract { + var nextPage: Int { get } + var previousPage: Int { get } + + func reset() + func fetchingNextPageDidSucceed(isPageEnd: Bool) + func fetchingPreviousPageDidSucceed(isPageEnd: Bool) +} + + + +extension PagingCursorContract { + func getPageNumber(of direction: PageEndDirection) -> Int { + switch direction { + case .previous: + return self.previousPage + case .next: + return self.nextPage + } + } + + + func fetchingDidSucceed(for direction: PageEndDirection, isPageEnd: Bool) { + switch direction { + case .previous: + self.fetchingPreviousPageDidSucceed(isPageEnd: isPageEnd) + case .next: + self.fetchingNextPageDidSucceed(isPageEnd: isPageEnd) + } + } +} + + + +class PagingCursor: PagingCursorContract { + private(set) var nextPage: Int + private(set) var previousPage: Int + + + typealias Domain = (upperBound: Int, lowerBound: Int) + private let domain: Domain + private let initialNextPage = 1 + private let initialPreviousPage = 1 + + + init(whereMovingOn domain: Domain) { + self.domain = domain + self.nextPage = self.initialNextPage + self.previousPage = self.initialPreviousPage + } + + + func reset() { + self.nextPage = self.initialNextPage + self.previousPage = self.initialPreviousPage + } + + + func fetchingNextPageDidSucceed(isPageEnd: Bool) { + let isReachingDomainBound = self.domain.upperBound <= self.nextPage + + if isPageEnd || isReachingDomainBound { + return + } + + self.nextPage += 1 + } + + + func fetchingPreviousPageDidSucceed(isPageEnd: Bool) { + let isReachingDomainBound = self.domain.lowerBound >= self.nextPage + + if isPageEnd || isReachingDomainBound { + return + } + + self.previousPage += 1 + } + + + static let standardDomain: Domain = ( + upperBound: Int.max, + lowerBound: 1 + ) +} diff --git a/TestableDesignExample/MvcArchitecture/Paging/PagingCursorTests.swift b/TestableDesignExample/MvcArchitecture/Paging/PagingCursorTests.swift new file mode 100644 index 0000000..bb43043 --- /dev/null +++ b/TestableDesignExample/MvcArchitecture/Paging/PagingCursorTests.swift @@ -0,0 +1,154 @@ +import XCTest +@testable import TestableDesignExample + + +class PagingCursorTests: XCTestCase { + private struct TestCase { + let scenario: [Command] + let expected: (nextPage: Int, previousPage: Int) + + + enum Command { + case fetchNext(isPageEnd: Bool) + case fetchPrevious(isPageEnd: Bool) + + func perform(on cursor: PagingCursorContract) { + switch self { + case let .fetchNext(isPageEnd: isPageEnd): + cursor.fetchingNextPageDidSucceed(isPageEnd: isPageEnd) + case let .fetchPrevious(isPageEnd: isPageEnd): + cursor.fetchingPreviousPageDidSucceed(isPageEnd: isPageEnd) + } + } + } + } + + + + func testScenarios() { + let testCases: [UInt: TestCase] = [ + #line: TestCase( + scenario: [], + expected: ( + nextPage: 1, + previousPage: 1 + ) + ), + + #line: TestCase( + scenario: [ + .fetchPrevious(isPageEnd: false), + ], + expected: ( + nextPage: 1, + previousPage: 1 + ) + ), + + #line: TestCase( + scenario: [ + .fetchPrevious(isPageEnd: true), + ], + expected: ( + nextPage: 1, + previousPage: 1 + ) + ), + + #line: TestCase( + scenario: [ + .fetchNext(isPageEnd: false), + ], + expected: ( + nextPage: 2, + previousPage: 1 + ) + ), + + #line: TestCase( + scenario: [ + .fetchNext(isPageEnd: true), + ], + expected: ( + nextPage: 1, + previousPage: 1 + ) + ), + + #line: TestCase( + scenario: [ + .fetchNext(isPageEnd: true), + .fetchNext(isPageEnd: true), + ], + expected: ( + nextPage: 1, + previousPage: 1 + ) + ), + + #line: TestCase( + scenario: [ + .fetchNext(isPageEnd: false), + .fetchNext(isPageEnd: true), + ], + expected: ( + nextPage: 2, + previousPage: 1 + ) + ), + + #line: TestCase( + scenario: [ + .fetchNext(isPageEnd: false), + .fetchNext(isPageEnd: false), + ], + expected: ( + nextPage: 3, + previousPage: 1 + ) + ), + + #line: TestCase( + scenario: [ + .fetchNext(isPageEnd: true), + .fetchNext(isPageEnd: false), + ], + expected: ( + nextPage: 2, + previousPage: 1 + ) + ), + ] + + + testCases.forEach { lineAndTestCase in + let (line, testCase) = lineAndTestCase + + let cursor = PagingCursor(whereMovingOn: PagingCursor.standardDomain) + + testCase.scenario.forEach { command in + command.perform(on: cursor) + } + + XCTAssertEqual( + testCase.expected.nextPage, + cursor.nextPage, + line: line + ) + XCTAssertEqual( + testCase.expected.previousPage, + cursor.previousPage, + line: line + ) + } + } + + + + static var allTests: [(String, (PagingCursorTests) -> () throws -> Void)] { + return [ + ("testScenarios", self.testScenarios), + ] + } +} + diff --git a/TestableDesignExample/MvcArchitecture/Paging/PagingModel.swift b/TestableDesignExample/MvcArchitecture/Paging/PagingModel.swift new file mode 100644 index 0000000..f026bcc --- /dev/null +++ b/TestableDesignExample/MvcArchitecture/Paging/PagingModel.swift @@ -0,0 +1,190 @@ +import RxSwift + + + +/** + A model for paged API. + + # State Transition Diagram + + ``` + | + V + +-------------------------------------------------->*<----------------------------------------------------+ + | | | + | V | + | *<-------------------------------------------------+ | + | | | | + | +------------------------*-------------------------+ | | + | | | | | + | (success) (failed) | | + | +-- recover ---+ | +--------------+ | | | + | | V V V | V | | + | | +------------------------------------------+ | +--------------------------------------------+ | | + | +--| fetched(elements: [Element], error: nil) | | | fetched(elements: [Element], error: error) | | | + | +------------------------------------------+ | +--------------------------------------------+ | | + | | | | | | | : | + +----- clear -----+ | +--- recover ---+ | +----- clear -----+ + | | : + +----------------------->*<------------------------+ | + | | + fetchPrevious,fetchNext | + | | + +-----------------------+ | | + | | | | + fetchPrevious,fetchNext,clear,recover | | | + | V V | + | +-------------------------------+ | + +------------| fetching(elements: [Element]) | | + +-------------------------------+ | + | | + +--------------------------------------------------+ + ``` + */ +protocol PagingModelContract { + associatedtype Element: Hashable + + var didChange: RxSwift.Observable> { get } + + func fetchNext() + func fetchPrevious() + func clear() + func recover() +} + + + +enum PagingModelState { + case fetched(elements: [Element], error: ModelError?) + case fetching(beforeElements: [Element]) + + + enum ModelError: Error { + case unspecified(debugInfo: String) + } +} + + + +class PagingModel: PagingModelContract { + typealias Element = T + + + var didChange: Observable> { + return self.stateVariable.asObservable() + } + + + private let stateVariable: RxSwift.Variable> + private let pageRepository: AnyPageRepository + private let pageEndStrategy: AnyPageEndDetectionStrategy + private let cursor: PagingCursorContract + + + fileprivate(set) var currentState: PagingModelState { + get { + return self.stateVariable.value + } + set { + self.stateVariable.value = newValue + } + } + + + init< + PageRepository: PageRepositoryContract, + PageEndStrategy: PageEndDetectionStrategyContract + >( + fetchingPageVia pageRepository: PageRepository, + detectingPageEndBy pageEndStrategy: PageEndStrategy, + choosingPageNumberBy cursor: PagingCursorContract + ) where + PageRepository.Element == Element, + PageEndStrategy.Element == Element + { + self.stateVariable = RxSwift.Variable>(.fetched( + elements: [], + error: nil + )) + + self.pageRepository = pageRepository.asAny() + self.pageEndStrategy = pageEndStrategy.asAny() + self.cursor = cursor + } + + + func fetchNext() { + self.fetch(to: .next) + } + + + func fetchPrevious() { + self.fetch(to: .previous) + } + + + func clear() { + switch self.currentState { + case .fetching: + return + + case .fetched: + self.cursor.reset() + self.currentState = .fetched(elements: [], error: nil) + } + } + + + func recover() { + switch self.currentState { + case .fetching: + return + + case let .fetched(elements: storedCollection, error: _): + self.currentState = .fetched(elements: storedCollection, error: nil) + } + } + + + private func fetch(to direction: PageEndDirection) { + switch self.currentState { + case .fetching: + return + + case let .fetched(elements: storedElements, error: _): + self.currentState = .fetching(beforeElements: storedElements) + + let pageNumber = self.cursor.getPageNumber(of: direction) + + self.pageRepository + .fetch(pageOf: pageNumber) + .then { (fetchedElements: [Element]) -> Void in + let isPageEnd = self.pageEndStrategy.isPageEnd( + fetching: pageNumber, + to: direction, + storedCollection: storedElements, + fetchedCollection: fetchedElements + ) + + self.cursor.fetchingDidSucceed( + for: direction, + isPageEnd: isPageEnd + ) + + self.currentState = .fetched( + elements: PagedElementCollection.append( + storedElements, + and: fetchedElements + ), + error: nil + ) + } + .catch { error in + self.currentState = .fetched( + elements: storedElements, + error: .unspecified(debugInfo: "\(error)") + ) + } + } + } +} diff --git a/TestableDesignExample/MvcArchitecture/Paging/PagingModelTests.swift b/TestableDesignExample/MvcArchitecture/Paging/PagingModelTests.swift new file mode 100644 index 0000000..3b509e7 --- /dev/null +++ b/TestableDesignExample/MvcArchitecture/Paging/PagingModelTests.swift @@ -0,0 +1,225 @@ +import XCTest +import RxBlocking +import MirrorDiffKit +import PromiseKit +@testable import TestableDesignExample + + +class PagingModelTests: XCTestCase { + func testInitialState() { + let pagingModel = PagingModelTests.createPagingModel( + fetchingPageVia: PagingModelTests.createPageRepositoryStub() + ) + + let actual = pagingModel.currentState + let expected = PagingModelState.fetched(elements: [Element](), error: nil) + + XCTAssert( + Diffable.from(any: actual) =~ Diffable.from(any: expected), + diff(between: expected, and: actual) + ) + } + + + func testStateAfterFetchNext() { + let pagingModel = PagingModelTests.createPagingModel( + fetchingPageVia: PagingModelTests.createPageRepositoryStub(), + whereCursorMovingOn: 1..( + fetchingPageVia pageRepository: PageRepository, + whereCursorMovingOn range: Range = 1.. PagingModel where PageRepository.Element == Element { + return PagingModel( + fetchingPageVia: pageRepository, + detectingPageEndBy: PageEndDetectionStrategyStub( + whereCursorMovingOn: range + ), + choosingPageNumberBy: PagingCursor( + whereMovingOn: PagingCursor.standardDomain + ) + ) + } + + + private static func createPageRepositoryStub() -> PageRepositoryStub { + return PageRepositoryStub( + willReturn: { pageNumber in + return Promise(value: [ + Element(pageNumber: pageNumber), + ]) + } + ) + } + + + private static func waitUntilFetched(_ pagingModel: PagingModel) { + _ = try! pagingModel + .didChange + .filter { state in + switch state { + case .fetching: + return false + case .fetched: + return true + } + } + .take(1) + .toBlocking() + .first() + } + + + private struct Element: Hashable { + let pageNumber: Int + + + var hashValue: Int { + return self.pageNumber + } + + + public static func ==(lhs: Element, rhs: Element) -> Bool { + return lhs.pageNumber == rhs.pageNumber + } + } +} +