diff --git a/Feels.xcodeproj/project.pbxproj b/Feels.xcodeproj/project.pbxproj index ec406dc..97687ea 100644 --- a/Feels.xcodeproj/project.pbxproj +++ b/Feels.xcodeproj/project.pbxproj @@ -7,8 +7,9 @@ objects = { /* Begin PBXBuildFile section */ - 1C0DAB51279DB0FB003B1F21 /* Feels/Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 1C0DAB50279DB0FB003B1F21 /* Feels/Localizable.xcstrings */; }; - 1C0DAB52279DB0FB003B1F22 /* Feels/Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 1C0DAB50279DB0FB003B1F21 /* Feels/Localizable.xcstrings */; }; + 06E4767B5977FAC8B644FC92 /* IntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CFAE86F485C853DB3239DD9 /* IntegrationTests.swift */; }; + 1C0DAB51279DB0FB003B1F21 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 1C0DAB50279DB0FB003B1F21 /* Localizable.xcstrings */; }; + 1C0DAB52279DB0FB003B1F22 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 1C0DAB50279DB0FB003B1F21 /* Localizable.xcstrings */; }; 1C9566442EF8F5F70032E68F /* Algorithms in Frameworks */ = {isa = PBXBuildFile; productRef = 1C9566432EF8F5F70032E68F /* Algorithms */; }; 1CB4D0A028787D8A00902A56 /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1CB4D09F28787D8A00902A56 /* StoreKit.framework */; }; 1CD90B07278C7DE0001C4FEA /* Tests_iOS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CD90B06278C7DE0001C4FEA /* Tests_iOS.swift */; }; @@ -24,6 +25,10 @@ 1CDEFBBF2F3B8736006AE6A1 /* Configuration.storekit in Resources */ = {isa = PBXBuildFile; fileRef = 1CDEFBBE2F3B8736006AE6A1 /* Configuration.storekit */; }; 1CDEFBC02F3B8736006AE6A1 /* Configuration.storekit in Resources */ = {isa = PBXBuildFile; fileRef = 1CDEFBBE2F3B8736006AE6A1 /* Configuration.storekit */; }; 46F07FA9D330456697C9AC29 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1CD90B47278C7E7A001C4FEA /* WidgetKit.framework */; }; + 4F1C717B7747918A459322CB /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F4D304CD05CC7C662CCD7DCB /* Foundation.framework */; }; + 54259F7B3F4E959B3F4055E4 /* StreakTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29E2A2FC314F88244CA946BF /* StreakTests.swift */; }; + 9559409B5AEEAB40EBCB6AF9 /* VoteLogicsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD717F91BD65382B7DDFE3C4 /* VoteLogicsTests.swift */; }; + EEB21B1CAA8EAEB497BD9FB3 /* DataControllerCRUDTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5566271983AEDF1D33C34FE6 /* DataControllerCRUDTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -48,6 +53,13 @@ remoteGlobalIDString = 1CD90B44278C7E7A001C4FEA; remoteInfo = FeelsWidgetExtension; }; + A0B973C7674930232515563A /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 1CD90AE6278C7DDF001C4FEA /* Project object */; + proxyType = 1; + remoteGlobalIDString = 1CD90AF4278C7DE0001C4FEA; + remoteInfo = "Feels (iOS)"; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -65,7 +77,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 1C0DAB50279DB0FB003B1F21 /* Feels/Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Feels/Localizable.xcstrings; sourceTree = ""; }; + 1C0DAB50279DB0FB003B1F21 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Feels/Localizable.xcstrings; sourceTree = ""; }; 1CB4D09F28787D8A00902A56 /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS15.5.sdk/System/Library/Frameworks/StoreKit.framework; sourceTree = DEVELOPER_DIR; }; 1CD90AF5278C7DE0001C4FEA /* Feels.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Feels.app; sourceTree = BUILT_PRODUCTS_DIR; }; 1CD90AFB278C7DE0001C4FEA /* Feels.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Feels.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -87,12 +99,18 @@ 1CD90B70278C8000001C4FEA /* Feels (iOS)Dev.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = "Feels (iOS)Dev.entitlements"; sourceTree = ""; }; 1CDEFBBE2F3B8736006AE6A1 /* Configuration.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = Configuration.storekit; sourceTree = ""; }; 1E594AEAB5F046E3B3ED7C47 /* Feels Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Feels Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; }; - B60015D02A064FF582E232FD /* Feels Watch App/Feels Watch AppDebug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Feels Watch App/Feels Watch AppDebug.entitlements"; sourceTree = ""; }; - B8AB4CD73C2B4DC89C6FE84D /* Feels Watch App/Feels Watch App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Feels Watch App/Feels Watch App.entitlements"; sourceTree = ""; }; + 29E2A2FC314F88244CA946BF /* StreakTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StreakTests.swift; sourceTree = ""; }; + 5566271983AEDF1D33C34FE6 /* DataControllerCRUDTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DataControllerCRUDTests.swift; sourceTree = ""; }; + 9CFAE86F485C853DB3239DD9 /* IntegrationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IntegrationTests.swift; sourceTree = ""; }; + B60015D02A064FF582E232FD /* Feels Watch AppDebug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Feels Watch App/Feels Watch AppDebug.entitlements"; sourceTree = ""; }; + B8AB4CD73C2B4DC89C6FE84D /* Feels Watch App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Feels Watch App/Feels Watch App.entitlements"; sourceTree = ""; }; + DA0D74ACDD741CFA1F14F50F /* FeelsTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FeelsTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + DD717F91BD65382B7DDFE3C4 /* VoteLogicsTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VoteLogicsTests.swift; sourceTree = ""; }; + F4D304CD05CC7C662CCD7DCB /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ - 1C000C162EE93AE3009C9ED5 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + 1C000C162EE93AE3009C9ED5 /* Exceptions for "Shared" folder in "FeelsWidgetExtension" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( "Color+Codable.swift", @@ -122,7 +140,7 @@ ); target = 1CD90B44278C7E7A001C4FEA /* FeelsWidgetExtension */; }; - 2166CE8AA7264FC2B4BFAAAC /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + 2166CE8AA7264FC2B4BFAAAC /* Exceptions for "Shared" folder in "Feels Watch App" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( Models/Mood.swift, @@ -137,12 +155,52 @@ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ - 1C00073D2EE9388A009C9ED5 /* Shared */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (2166CE8AA7264FC2B4BFAAAC /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 1C000C162EE93AE3009C9ED5 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Shared; sourceTree = ""; }; - 1C0009922EE938FC009C9ED5 /* FeelsWidget2 */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = FeelsWidget2; sourceTree = ""; }; - 579031D619ED4B989145EEB1 /* Feels Watch App */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = "Feels Watch App"; sourceTree = ""; }; + 1C00073D2EE9388A009C9ED5 /* Shared */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 2166CE8AA7264FC2B4BFAAAC /* Exceptions for "Shared" folder in "Feels Watch App" target */, + 1C000C162EE93AE3009C9ED5 /* Exceptions for "Shared" folder in "FeelsWidgetExtension" target */, + ); + explicitFileTypes = { + }; + explicitFolders = ( + ); + path = Shared; + sourceTree = ""; + }; + 1C0009922EE938FC009C9ED5 /* FeelsWidget2 */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); + explicitFileTypes = { + }; + explicitFolders = ( + ); + path = FeelsWidget2; + sourceTree = ""; + }; + 579031D619ED4B989145EEB1 /* Feels Watch App */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); + explicitFileTypes = { + }; + explicitFolders = ( + ); + path = "Feels Watch App"; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ + 0DC68E3188164EBC373A6BF3 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 4F1C717B7747918A459322CB /* Foundation.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 1CD90AF2278C7DE0001C4FEA /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -199,9 +257,9 @@ 1CD90AE5278C7DDF001C4FEA = { isa = PBXGroup; children = ( - B8AB4CD73C2B4DC89C6FE84D /* Feels Watch App/Feels Watch App.entitlements */, - B60015D02A064FF582E232FD /* Feels Watch App/Feels Watch AppDebug.entitlements */, - 1C0DAB50279DB0FB003B1F21 /* Feels/Localizable.xcstrings */, + B8AB4CD73C2B4DC89C6FE84D /* Feels Watch App.entitlements */, + B60015D02A064FF582E232FD /* Feels Watch AppDebug.entitlements */, + 1C0DAB50279DB0FB003B1F21 /* Localizable.xcstrings */, 1CDEFBBE2F3B8736006AE6A1 /* Configuration.storekit */, 1CD90B6A278C7F75001C4FEA /* Feels (iOS).entitlements */, 1CD90B70278C8000001C4FEA /* Feels (iOS)Dev.entitlements */, @@ -216,6 +274,7 @@ 1CD90B11278C7DE0001C4FEA /* Tests macOS */, 1CD90B46278C7E7A001C4FEA /* Frameworks */, 1CD90AF6278C7DE0001C4FEA /* Products */, + 38D005587E22737DC6291955 /* FeelsTests */, ); sourceTree = ""; }; @@ -228,6 +287,7 @@ 1CD90B02278C7DE0001C4FEA /* Tests iOS.xctest */, 1CD90B0E278C7DE0001C4FEA /* Tests macOS.xctest */, 1CD90B45278C7E7A001C4FEA /* FeelsWidgetExtension.appex */, + DA0D74ACDD741CFA1F14F50F /* FeelsTests.xctest */, ); name = Products; sourceTree = ""; @@ -265,10 +325,31 @@ 1CD90B6B278C7F78001C4FEA /* CloudKit.framework */, 1CD90B47278C7E7A001C4FEA /* WidgetKit.framework */, 1CD90B49278C7E7A001C4FEA /* SwiftUI.framework */, + 88F4C25CA0D11FB136B0B8A6 /* iOS */, ); name = Frameworks; sourceTree = ""; }; + 38D005587E22737DC6291955 /* FeelsTests */ = { + isa = PBXGroup; + children = ( + 5566271983AEDF1D33C34FE6 /* DataControllerCRUDTests.swift */, + 9CFAE86F485C853DB3239DD9 /* IntegrationTests.swift */, + 29E2A2FC314F88244CA946BF /* StreakTests.swift */, + DD717F91BD65382B7DDFE3C4 /* VoteLogicsTests.swift */, + ); + name = FeelsTests; + path = FeelsTests; + sourceTree = ""; + }; + 88F4C25CA0D11FB136B0B8A6 /* iOS */ = { + isa = PBXGroup; + children = ( + F4D304CD05CC7C662CCD7DCB /* Foundation.framework */, + ); + name = iOS; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -367,8 +448,6 @@ 1C0009922EE938FC009C9ED5 /* FeelsWidget2 */, ); name = FeelsWidgetExtension; - packageProductDependencies = ( - ); productName = FeelsWidgetExtension; productReference = 1CD90B45278C7E7A001C4FEA /* FeelsWidgetExtension.appex */; productType = "com.apple.product-type.app-extension"; @@ -389,12 +468,28 @@ 579031D619ED4B989145EEB1 /* Feels Watch App */, ); name = "Feels Watch App"; - packageProductDependencies = ( - ); productName = "Feels Watch App"; productReference = 1E594AEAB5F046E3B3ED7C47 /* Feels Watch App.app */; productType = "com.apple.product-type.application"; }; + B375A511826E3AB53E2CF51A /* FeelsTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 611E0B1E1241C11626465A8D /* Build configuration list for PBXNativeTarget "FeelsTests" */; + buildPhases = ( + 681C769809C145ECC6A2AE8B /* Sources */, + 0DC68E3188164EBC373A6BF3 /* Frameworks */, + AE59E2C6BF9FA0FBBE07A123 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 946F2D1B29B91CD7DB732908 /* PBXTargetDependency */, + ); + name = FeelsTests; + productName = FeelsTests; + productReference = DA0D74ACDD741CFA1F14F50F /* FeelsTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -425,6 +520,10 @@ B1DB9E6543DE4A009DB00916 = { CreatedOnToolsVersion = 15.0; }; + B375A511826E3AB53E2CF51A = { + CreatedOnToolsVersion = 16.0; + TestTargetID = 1CD90AF4278C7DE0001C4FEA; + }; }; }; buildConfigurationList = 1CD90AE9278C7DDF001C4FEA /* Build configuration list for PBXProject "Feels" */; @@ -456,6 +555,7 @@ 1CD90AFA278C7DE0001C4FEA /* Feels (macOS) */, 1CD90B01278C7DE0001C4FEA /* Tests iOS */, 1CD90B0D278C7DE0001C4FEA /* Tests macOS */, + B375A511826E3AB53E2CF51A /* FeelsTests */, ); }; /* End PBXProject section */ @@ -472,7 +572,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 1C0DAB51279DB0FB003B1F21 /* Feels/Localizable.xcstrings in Resources */, + 1C0DAB51279DB0FB003B1F21 /* Localizable.xcstrings in Resources */, 1CDEFBBF2F3B8736006AE6A1 /* Configuration.storekit in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -503,7 +603,14 @@ buildActionMask = 2147483647; files = ( 1CDEFBC02F3B8736006AE6A1 /* Configuration.storekit in Resources */, - 1C0DAB52279DB0FB003B1F22 /* Feels/Localizable.xcstrings in Resources */, + 1C0DAB52279DB0FB003B1F22 /* Localizable.xcstrings in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + AE59E2C6BF9FA0FBBE07A123 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( ); runOnlyForDeploymentPostprocessing = 0; }; @@ -556,6 +663,17 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 681C769809C145ECC6A2AE8B /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + EEB21B1CAA8EAEB497BD9FB3 /* DataControllerCRUDTests.swift in Sources */, + 06E4767B5977FAC8B644FC92 /* IntegrationTests.swift in Sources */, + 54259F7B3F4E959B3F4055E4 /* StreakTests.swift in Sources */, + 9559409B5AEEAB40EBCB6AF9 /* VoteLogicsTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -574,6 +692,12 @@ target = 1CD90B44278C7E7A001C4FEA /* FeelsWidgetExtension */; targetProxy = 1CD90B54278C7E7A001C4FEA /* PBXContainerItemProxy */; }; + 946F2D1B29B91CD7DB732908 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = "Feels (iOS)"; + target = 1CD90AF4278C7DE0001C4FEA /* Feels (iOS) */; + targetProxy = A0B973C7674930232515563A /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -1020,6 +1144,24 @@ }; name = Release; }; + 298BB8B78DE12C7707210902 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = QND55P4443; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + PRODUCT_BUNDLE_IDENTIFIER = com.88oakapps.feels.tests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Feels.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Feels"; + }; + name = Debug; + }; 67FBFEE92D1D4F8BBFBF7B1D /* Release */ = { isa = XCBuildConfiguration; buildSettings = { @@ -1052,6 +1194,25 @@ }; name = Release; }; + C9B28244C1A36D4F2FE7E61A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = QND55P4443; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + PRODUCT_BUNDLE_IDENTIFIER = com.88oakapps.feels.tests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Feels.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Feels"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -1118,6 +1279,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 611E0B1E1241C11626465A8D /* Build configuration list for PBXNativeTarget "FeelsTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C9B28244C1A36D4F2FE7E61A /* Release */, + 298BB8B78DE12C7707210902 /* Debug */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ diff --git a/Feels.xcodeproj/xcshareddata/xcschemes/Feels (iOS).xcscheme b/Feels.xcodeproj/xcshareddata/xcschemes/Feels (iOS).xcscheme index 904e5cf..bf1367f 100644 --- a/Feels.xcodeproj/xcshareddata/xcschemes/Feels (iOS).xcscheme +++ b/Feels.xcodeproj/xcshareddata/xcschemes/Feels (iOS).xcscheme @@ -32,9 +32,9 @@ skipped = "NO"> diff --git a/Feels/Localizable.xcstrings b/Feels/Localizable.xcstrings index 7159e0a..2ca5bd4 100644 --- a/Feels/Localizable.xcstrings +++ b/Feels/Localizable.xcstrings @@ -2718,6 +2718,10 @@ } } }, + "Bypass Subscription" : { + "comment" : "A label describing a feature that allows users to bypass in-app purchases for testing purposes.", + "isCommentAutoGenerated" : true + }, "Cancel" : { "comment" : "The text for a button that dismisses the current view.", "isCommentAutoGenerated" : true, @@ -4679,6 +4683,10 @@ } } }, + "Data Storage Unavailable" : { + "comment" : "An alert title when the app cannot save mood data permanently.", + "isCommentAutoGenerated" : true + }, "Date Range" : { "comment" : "A label describing the date range for the mood data being exported.", "isCommentAutoGenerated" : true, @@ -7791,6 +7799,14 @@ } } }, + "Help improve Feels by sharing anonymous usage data" : { + "comment" : "A description of the analytics toggle.", + "isCommentAutoGenerated" : true + }, + "Hide trial banner & grant full access" : { + "comment" : "A description of the feature that hides the trial banner and grants full access to the app.", + "isCommentAutoGenerated" : true + }, "How are you feeling?" : { "localizations" : { "de" : { @@ -11003,6 +11019,10 @@ "comment" : "A button label that pauses a live activity.", "isCommentAutoGenerated" : true }, + "Payment Issue" : { + "comment" : "A label indicating that there is a payment issue with a subscription.", + "isCommentAutoGenerated" : true + }, "Paywall Styles" : { "localizations" : { "de" : { @@ -14182,6 +14202,10 @@ } } }, + "Share Analytics" : { + "comment" : "A label describing the purpose of the \"Share Analytics\" toggle.", + "isCommentAutoGenerated" : true + }, "share_view_all_moods_total_template_title" : { "extractionState" : "manual", "localizations" : { @@ -16147,6 +16171,10 @@ } } }, + "Toggle anonymous usage analytics" : { + "comment" : "A hint that describes the functionality of the \"Share Analytics\" toggle.", + "isCommentAutoGenerated" : true + }, "Top Mood" : { "comment" : "A label displayed above the top mood value in the month card.", "isCommentAutoGenerated" : true, @@ -18036,6 +18064,10 @@ } } }, + "Your mood data cannot be saved permanently. Please restart the app. If the problem persists, reinstall the app." : { + "comment" : "An alert message displayed when the user's mood data cannot be saved permanently. It instructs the user to restart the app or reinstall it if the issue persists.", + "isCommentAutoGenerated" : true + }, "Your Personal\nDiary" : { "comment" : "A title describing the main feature of the premium subscription: a personal diary.", "isCommentAutoGenerated" : true, diff --git a/FeelsTests/DataControllerCRUDTests.swift b/FeelsTests/DataControllerCRUDTests.swift new file mode 100644 index 0000000..25c0549 --- /dev/null +++ b/FeelsTests/DataControllerCRUDTests.swift @@ -0,0 +1,366 @@ +// +// DataControllerCRUDTests.swift +// FeelsTests +// +// Tests for DataController create, read, update, and delete operations. +// + +import XCTest +import SwiftData +@testable import Feels + +@MainActor +final class DataControllerCreateTests: XCTestCase { + var sut: DataController! + + override func setUp() { + super.setUp() + let schema = Schema([MoodEntryModel.self]) + let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + sut = DataController(container: try! ModelContainer(for: schema, configurations: [config])) + } + + override func tearDown() { + sut = nil + super.tearDown() + } + + // MARK: - Phase 2: Create Tests + + func test_add_insertsEntry() { + let date = makeDate(2024, 6, 15) + sut.add(mood: .great, forDate: date, entryType: .listView) + + let entry = sut.getEntry(byDate: date) + XCTAssertNotNil(entry, "Entry should exist after add") + XCTAssertEqual(entry?.mood, .great) + XCTAssertEqual(entry?.entryType, EntryType.listView.rawValue) + } + + func test_add_replacesExistingForSameDate() { + let date = makeDate(2024, 6, 15) + sut.add(mood: .great, forDate: date, entryType: .listView) + sut.add(mood: .bad, forDate: date, entryType: .widget) + + let allEntries = sut.getAllEntries(byDate: date) + XCTAssertEqual(allEntries.count, 1, "Should have exactly 1 entry after 2 adds for same date") + XCTAssertEqual(allEntries.first?.mood, .bad, "Should keep the latest mood") + } + + func test_add_setsCorrectWeekday() { + // June 15, 2024 is a Saturday (weekday = 7 in Gregorian) + let date = makeDate(2024, 6, 15) + sut.add(mood: .average, forDate: date, entryType: .listView) + + let entry = sut.getEntry(byDate: date) + let expectedWeekday = Calendar.current.component(.weekday, from: date) + XCTAssertEqual(entry?.weekDay, expectedWeekday) + } + + func test_add_allMoodValues() { + let moods: [Mood] = [.horrible, .bad, .average, .good, .great] + for (i, mood) in moods.enumerated() { + let date = makeDate(2024, 6, 10 + i) + sut.add(mood: mood, forDate: date, entryType: .listView) + + let entry = sut.getEntry(byDate: date) + XCTAssertEqual(entry?.mood, mood, "Mood \(mood) should persist and read back correctly") + } + } + + func test_addBatch_insertsMultiple() { + let entries: [(mood: Mood, date: Date, entryType: EntryType)] = [ + (.great, makeDate(2024, 6, 10), .listView), + (.good, makeDate(2024, 6, 11), .widget), + (.average, makeDate(2024, 6, 12), .watch), + ] + sut.addBatch(entries: entries) + + for (mood, date, _) in entries { + let entry = sut.getEntry(byDate: date) + XCTAssertNotNil(entry, "Entry for \(date) should exist") + XCTAssertEqual(entry?.mood, mood) + } + } + + func test_addBatch_replacesExisting() { + let date = makeDate(2024, 6, 10) + sut.add(mood: .horrible, forDate: date, entryType: .listView) + + let batchEntries: [(mood: Mood, date: Date, entryType: EntryType)] = [ + (.great, date, .widget), + ] + sut.addBatch(entries: batchEntries) + + let allEntries = sut.getAllEntries(byDate: date) + XCTAssertEqual(allEntries.count, 1) + XCTAssertEqual(allEntries.first?.mood, .great) + } +} + +// MARK: - Phase 3: Read Tests + +@MainActor +final class DataControllerReadTests: XCTestCase { + var sut: DataController! + + override func setUp() { + super.setUp() + let schema = Schema([MoodEntryModel.self]) + let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + sut = DataController(container: try! ModelContainer(for: schema, configurations: [config])) + } + + override func tearDown() { + sut = nil + super.tearDown() + } + + func test_getEntry_emptyDB_returnsNil() { + let entry = sut.getEntry(byDate: Date()) + XCTAssertNil(entry) + } + + func test_getEntry_boundaryExclusion() { + // Insert entry at exactly midnight of Jan 2 (start of Jan 2) + // Use hour=0 to place it precisely at the boundary + let jan2Midnight = makeDate(2024, 1, 2, hour: 0) + let entry = MoodEntryModel(forDate: jan2Midnight, mood: .great, entryType: .listView) + sut.modelContext.insert(entry) + sut.save() + + // Query for Jan 1 — should NOT find the Jan 2 entry + // getEntry builds range [Jan 1 00:00, Jan 2 00:00] — with <= the midnight entry leaks in + let jan1 = makeDate(2024, 1, 1) + let result = sut.getEntry(byDate: jan1) + XCTAssertNil(result, "Entry at midnight Jan 2 should NOT be returned when querying Jan 1") + } + + func test_getData_filtersWeekdays() { + // Add entries for a full week (Mon-Sun) + // Jan 1, 2024 is a Monday (weekday=2) + for i in 0..<7 { + let date = Calendar.current.date(byAdding: .day, value: i, to: makeDate(2024, 1, 1))! + sut.add(mood: .average, forDate: date, entryType: .listView) + } + + // Filter to only weekdays 2 (Mon) and 6 (Fri) + let startDate = makeDate(2024, 1, 1) + let endDate = makeDate(2024, 1, 7) + let results = sut.getData(startDate: startDate, endDate: endDate, includedDays: [2, 6]) + + XCTAssertEqual(results.count, 2, "Should return only Monday and Friday") + } + + func test_getData_emptyIncludedDays_returnsAll() { + for i in 0..<7 { + let date = Calendar.current.date(byAdding: .day, value: i, to: makeDate(2024, 1, 1))! + sut.add(mood: .average, forDate: date, entryType: .listView) + } + + let startDate = makeDate(2024, 1, 1) + let endDate = makeDate(2024, 1, 7) + let results = sut.getData(startDate: startDate, endDate: endDate, includedDays: []) + + XCTAssertEqual(results.count, 7, "Empty includedDays should return all 7 entries") + } + + func test_splitIntoYearMonth_groupsCorrectly() { + // Add entries in different months + sut.add(mood: .great, forDate: makeDate(2024, 1, 15), entryType: .listView) + sut.add(mood: .good, forDate: makeDate(2024, 1, 20), entryType: .listView) + sut.add(mood: .bad, forDate: makeDate(2024, 3, 5), entryType: .listView) + + let grouped = sut.splitIntoYearMonth(includedDays: []) + XCTAssertEqual(grouped[2024]?[1]?.count, 2, "Jan 2024 should have 2 entries") + XCTAssertEqual(grouped[2024]?[3]?.count, 1, "Mar 2024 should have 1 entry") + XCTAssertNil(grouped[2024]?[2], "Feb 2024 should have no entries") + } + + func test_earliestEntry() { + sut.add(mood: .great, forDate: makeDate(2024, 6, 15), entryType: .listView) + sut.add(mood: .bad, forDate: makeDate(2024, 1, 1), entryType: .listView) + sut.add(mood: .good, forDate: makeDate(2024, 3, 10), entryType: .listView) + + let earliest = sut.earliestEntry + XCTAssertNotNil(earliest) + XCTAssertTrue(Calendar.current.isDate(earliest!.forDate, inSameDayAs: makeDate(2024, 1, 1))) + } + + func test_latestEntry() { + sut.add(mood: .great, forDate: makeDate(2024, 6, 15), entryType: .listView) + sut.add(mood: .bad, forDate: makeDate(2024, 1, 1), entryType: .listView) + sut.add(mood: .good, forDate: makeDate(2024, 3, 10), entryType: .listView) + + let latest = sut.latestEntry + XCTAssertNotNil(latest) + XCTAssertTrue(Calendar.current.isDate(latest!.forDate, inSameDayAs: makeDate(2024, 6, 15))) + } +} + +// MARK: - Phase 4: Update Tests + +@MainActor +final class DataControllerUpdateTests: XCTestCase { + var sut: DataController! + + override func setUp() { + super.setUp() + let schema = Schema([MoodEntryModel.self]) + let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + sut = DataController(container: try! ModelContainer(for: schema, configurations: [config])) + } + + override func tearDown() { + sut = nil + super.tearDown() + } + + func test_update_changesMood() { + let date = makeDate(2024, 6, 15) + sut.add(mood: .bad, forDate: date, entryType: .listView) + + let result = sut.update(entryDate: date, withMood: .great) + XCTAssertTrue(result) + + let entry = sut.getEntry(byDate: date) + XCTAssertEqual(entry?.mood, .great) + } + + func test_update_nonexistent_returnsFalse() { + let result = sut.update(entryDate: makeDate(2024, 6, 15), withMood: .great) + XCTAssertFalse(result) + } + + func test_updateNotes_setsNotes() { + let date = makeDate(2024, 6, 15) + sut.add(mood: .good, forDate: date, entryType: .listView) + + let result = sut.updateNotes(forDate: date, notes: "Had a great day") + XCTAssertTrue(result) + + let entry = sut.getEntry(byDate: date) + XCTAssertEqual(entry?.notes, "Had a great day") + } + + func test_updateNotes_nilClearsNotes() { + let date = makeDate(2024, 6, 15) + sut.add(mood: .good, forDate: date, entryType: .listView) + sut.updateNotes(forDate: date, notes: "Some notes") + + let result = sut.updateNotes(forDate: date, notes: nil) + XCTAssertTrue(result) + + let entry = sut.getEntry(byDate: date) + XCTAssertNil(entry?.notes) + } + + func test_updatePhoto_setsPhotoID() { + let date = makeDate(2024, 6, 15) + sut.add(mood: .good, forDate: date, entryType: .listView) + + let photoID = UUID() + let result = sut.updatePhoto(forDate: date, photoID: photoID) + XCTAssertTrue(result) + + let entry = sut.getEntry(byDate: date) + XCTAssertEqual(entry?.photoID, photoID) + } + + func test_update_refreshesTimestamp() { + let date = makeDate(2024, 6, 15) + sut.add(mood: .bad, forDate: date, entryType: .listView) + + let originalTimestamp = sut.getEntry(byDate: date)!.timestamp + + // Small delay so timestamp would differ + let result = sut.update(entryDate: date, withMood: .great) + XCTAssertTrue(result) + + let updatedEntry = sut.getEntry(byDate: date)! + XCTAssertGreaterThanOrEqual(updatedEntry.timestamp, originalTimestamp, + "Timestamp should be updated on mood change so removeDuplicates picks the right entry") + } +} + +// MARK: - Phase 5: Delete Tests + +@MainActor +final class DataControllerDeleteTests: XCTestCase { + var sut: DataController! + + override func setUp() { + super.setUp() + let schema = Schema([MoodEntryModel.self]) + let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + sut = DataController(container: try! ModelContainer(for: schema, configurations: [config])) + } + + override func tearDown() { + sut = nil + super.tearDown() + } + + func test_clearDB_removesAll() { + sut.add(mood: .great, forDate: makeDate(2024, 6, 10), entryType: .listView) + sut.add(mood: .bad, forDate: makeDate(2024, 6, 11), entryType: .listView) + sut.add(mood: .good, forDate: makeDate(2024, 6, 12), entryType: .listView) + + sut.clearDB() + + let all = sut.getData(startDate: Date(timeIntervalSince1970: 0), endDate: Date(), includedDays: []) + XCTAssertTrue(all.isEmpty, "clearDB should remove all entries") + } + + func test_deleteAllEntries_removesOnlyTargetDate() { + let date1 = makeDate(2024, 6, 10) + let date2 = makeDate(2024, 6, 11) + sut.add(mood: .great, forDate: date1, entryType: .listView) + sut.add(mood: .bad, forDate: date2, entryType: .listView) + + sut.deleteAllEntries(forDate: date1) + + XCTAssertNil(sut.getEntry(byDate: date1), "Deleted date should be gone") + XCTAssertNotNil(sut.getEntry(byDate: date2), "Other dates should be untouched") + } + + func test_removeDuplicates_keepsBestEntry() { + let date = makeDate(2024, 6, 15) + + // Manually insert 3 entries for the same date to simulate CloudKit conflict + let entry1 = MoodEntryModel(forDate: date, mood: .missing, entryType: .filledInMissing) + entry1.timestamp = makeDate(2024, 6, 15, hour: 8) + sut.modelContext.insert(entry1) + + let entry2 = MoodEntryModel(forDate: date, mood: .great, entryType: .listView) + entry2.timestamp = makeDate(2024, 6, 15, hour: 10) + sut.modelContext.insert(entry2) + + let entry3 = MoodEntryModel(forDate: date, mood: .good, entryType: .listView) + entry3.timestamp = makeDate(2024, 6, 15, hour: 9) + sut.modelContext.insert(entry3) + + sut.save() + + let removed = sut.removeDuplicates() + XCTAssertEqual(removed, 2, "Should remove 2 duplicates") + + let remaining = sut.getAllEntries(byDate: date) + XCTAssertEqual(remaining.count, 1, "Should keep exactly 1 entry") + // Should keep non-missing with most recent timestamp (entry2, .great at 10am) + XCTAssertEqual(remaining.first?.mood, .great, "Should keep the best entry (non-missing, most recent)") + } +} + +// MARK: - Test Helpers + +func makeDate(_ year: Int, _ month: Int, _ day: Int, hour: Int = 12) -> Date { + var components = DateComponents() + components.year = year + components.month = month + components.day = day + components.hour = hour + components.minute = 0 + components.second = 0 + return Calendar.current.date(from: components)! +} diff --git a/FeelsTests/IntegrationTests.swift b/FeelsTests/IntegrationTests.swift new file mode 100644 index 0000000..07e284f --- /dev/null +++ b/FeelsTests/IntegrationTests.swift @@ -0,0 +1,141 @@ +// +// IntegrationTests.swift +// FeelsTests +// +// Integration tests verifying full lifecycle pipelines. +// + +import XCTest +import SwiftData +@testable import Feels + +@MainActor +final class IntegrationTests: XCTestCase { + var sut: DataController! + + override func setUp() { + super.setUp() + let schema = Schema([MoodEntryModel.self]) + let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + sut = DataController(container: try! ModelContainer(for: schema, configurations: [config])) + } + + override func tearDown() { + sut = nil + super.tearDown() + } + + // MARK: - Phase 8: Integration Pipelines + + func test_fullCRUD_lifecycle() { + let date = makeDate(2024, 6, 15) + + // Create + sut.add(mood: .good, forDate: date, entryType: .listView) + XCTAssertNotNil(sut.getEntry(byDate: date)) + + // Read + let entry = sut.getEntry(byDate: date) + XCTAssertEqual(entry?.mood, .good) + + // Update + let updated = sut.update(entryDate: date, withMood: .great) + XCTAssertTrue(updated) + XCTAssertEqual(sut.getEntry(byDate: date)?.mood, .great) + + // Update notes + sut.updateNotes(forDate: date, notes: "Lifecycle test") + XCTAssertEqual(sut.getEntry(byDate: date)?.notes, "Lifecycle test") + + // Delete + sut.deleteAllEntries(forDate: date) + XCTAssertNil(sut.getEntry(byDate: date)) + } + + func test_streak_throughLifecycle() { + let today = Calendar.current.startOfDay(for: Date()) + let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: today)! + + // Add today → streak=1 + sut.add(mood: .good, forDate: today, entryType: .listView) + XCTAssertEqual(sut.calculateStreak(from: today).streak, 1) + + // Add yesterday → streak=2 + sut.add(mood: .great, forDate: yesterday, entryType: .listView) + XCTAssertEqual(sut.calculateStreak(from: today).streak, 2) + + // Update today's mood → streak still 2 + sut.update(entryDate: today, withMood: .average) + XCTAssertEqual(sut.calculateStreak(from: today).streak, 2) + + // Delete today → streak=1 (counting from yesterday) + sut.deleteAllEntries(forDate: today) + XCTAssertEqual(sut.calculateStreak(from: today).streak, 1) + } + + func test_voteStatus_throughLifecycle() { + let onboarding = OnboardingData() + onboarding.inputDay = .Today + // Set unlock time to 00:01 so we're always "after" unlock + var components = Calendar.current.dateComponents([.year, .month, .day], from: Date()) + components.hour = 0 + components.minute = 1 + onboarding.date = Calendar.current.date(from: components)! + + let votingDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: onboarding) + + // Helper: check vote status using testable DataController + func isVoteNeeded() -> Bool { + let entry = sut.getEntry(byDate: votingDate.startOfDay) + return entry == nil || entry?.mood == .missing + } + + // No entry → vote needed + XCTAssertTrue(isVoteNeeded()) + + // Add entry → no vote needed + sut.add(mood: .great, forDate: votingDate, entryType: .listView) + XCTAssertFalse(isVoteNeeded()) + + // Delete entry → vote needed again + sut.deleteAllEntries(forDate: votingDate) + XCTAssertTrue(isVoteNeeded()) + } + + func test_duplicateRemoval() { + let date = makeDate(2024, 6, 15) + + // Manually insert 3 entries for same date (simulating CloudKit conflict) + let entry1 = MoodEntryModel(forDate: date, mood: .great, entryType: .listView) + entry1.timestamp = makeDate(2024, 6, 15, hour: 10) + sut.modelContext.insert(entry1) + + let entry2 = MoodEntryModel(forDate: date, mood: .good, entryType: .listView) + entry2.timestamp = makeDate(2024, 6, 15, hour: 8) + sut.modelContext.insert(entry2) + + let entry3 = MoodEntryModel(forDate: date, mood: .missing, entryType: .filledInMissing) + entry3.timestamp = makeDate(2024, 6, 15, hour: 12) + sut.modelContext.insert(entry3) + + sut.save() + + let removed = sut.removeDuplicates() + XCTAssertEqual(removed, 2) + XCTAssertEqual(sut.getAllEntries(byDate: date).count, 1) + } + + func test_dataListeners_fireOnMutation() { + var listenerCallCount = 0 + sut.addNewDataListener { + listenerCallCount += 1 + } + + // add() calls saveAndRunDataListeners internally + sut.add(mood: .good, forDate: makeDate(2024, 6, 15), entryType: .listView) + + // add() does: delete-save (if existing) + insert + saveAndRunDataListeners + // For a fresh add with no existing entry, listener fires once from saveAndRunDataListeners + XCTAssertGreaterThanOrEqual(listenerCallCount, 1, "Listener should fire at least once on mutation") + } +} diff --git a/FeelsTests/StreakTests.swift b/FeelsTests/StreakTests.swift new file mode 100644 index 0000000..dbeccda --- /dev/null +++ b/FeelsTests/StreakTests.swift @@ -0,0 +1,125 @@ +// +// StreakTests.swift +// FeelsTests +// +// Tests for DataController streak calculation. +// + +import XCTest +import SwiftData +@testable import Feels + +@MainActor +final class StreakTests: XCTestCase { + var sut: DataController! + + override func setUp() { + super.setUp() + let schema = Schema([MoodEntryModel.self]) + let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + sut = DataController(container: try! ModelContainer(for: schema, configurations: [config])) + } + + override func tearDown() { + sut = nil + super.tearDown() + } + + // MARK: - Phase 6: Streak Calculation + + func test_streak_emptyDB_zero() { + let result = sut.calculateStreak(from: Date()) + XCTAssertEqual(result.streak, 0) + XCTAssertNil(result.todaysMood) + } + + func test_streak_singleEntryToday_one() { + let today = Calendar.current.startOfDay(for: Date()) + sut.add(mood: .great, forDate: today, entryType: .listView) + + let result = sut.calculateStreak(from: today) + XCTAssertEqual(result.streak, 1) + XCTAssertEqual(result.todaysMood, .great) + } + + func test_streak_consecutiveDays() { + let today = Calendar.current.startOfDay(for: Date()) + for i in 0..<5 { + let date = Calendar.current.date(byAdding: .day, value: -i, to: today)! + sut.add(mood: .good, forDate: date, entryType: .listView) + } + + let result = sut.calculateStreak(from: today) + XCTAssertEqual(result.streak, 5) + } + + func test_streak_gapBreaks() { + let today = Calendar.current.startOfDay(for: Date()) + // Today, yesterday, then skip a day, then 2 more + sut.add(mood: .good, forDate: today, entryType: .listView) + sut.add(mood: .good, forDate: Calendar.current.date(byAdding: .day, value: -1, to: today)!, entryType: .listView) + // Skip day -2 + sut.add(mood: .good, forDate: Calendar.current.date(byAdding: .day, value: -3, to: today)!, entryType: .listView) + sut.add(mood: .good, forDate: Calendar.current.date(byAdding: .day, value: -4, to: today)!, entryType: .listView) + + let result = sut.calculateStreak(from: today) + XCTAssertEqual(result.streak, 2, "Streak should stop at the gap") + } + + func test_streak_missingDoesNotCount() { + let today = Calendar.current.startOfDay(for: Date()) + sut.add(mood: .good, forDate: today, entryType: .listView) + // Yesterday is a .missing entry (should be ignored) + sut.add(mood: .missing, forDate: Calendar.current.date(byAdding: .day, value: -1, to: today)!, entryType: .filledInMissing) + sut.add(mood: .good, forDate: Calendar.current.date(byAdding: .day, value: -2, to: today)!, entryType: .listView) + + let result = sut.calculateStreak(from: today) + XCTAssertEqual(result.streak, 1, ".missing entries should not count toward streak") + } + + func test_streak_noEntryToday_countsFromYesterday() { + let today = Calendar.current.startOfDay(for: Date()) + let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: today)! + let twoDaysAgo = Calendar.current.date(byAdding: .day, value: -2, to: today)! + + sut.add(mood: .good, forDate: yesterday, entryType: .listView) + sut.add(mood: .great, forDate: twoDaysAgo, entryType: .listView) + + let result = sut.calculateStreak(from: today) + XCTAssertEqual(result.streak, 2, "Should count from yesterday when today has no entry") + XCTAssertNil(result.todaysMood, "Today's mood should be nil when no entry exists") + } + + func test_streak_entryAfterVotingTime_stillCounts() { + // Bug: calculateStreak passes votingDate (with time) as endDate to getData. + // getData uses <=, so an entry logged AFTER votingDate's time is excluded. + // E.g. if votingDate is "today at 8am" and user logged at 10am, the 10am entry + // is excluded from the query, making streak miss today. + let today = Calendar.current.startOfDay(for: Date()) + let morningVotingDate = Calendar.current.date(byAdding: .hour, value: 8, to: today)! + + // Add entry at 10am (after the 8am voting date time) + let afternoonDate = Calendar.current.date(byAdding: .hour, value: 10, to: today)! + sut.add(mood: .great, forDate: afternoonDate, entryType: .listView) + + let result = sut.calculateStreak(from: morningVotingDate) + XCTAssertEqual(result.streak, 1, "Entry logged after votingDate time should still count") + XCTAssertEqual(result.todaysMood, .great, "Today's mood should be found even if logged after votingDate time") + } + + func test_streak_afterDelete_decreases() { + let today = Calendar.current.startOfDay(for: Date()) + let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: today)! + + sut.add(mood: .good, forDate: today, entryType: .listView) + sut.add(mood: .great, forDate: yesterday, entryType: .listView) + + let beforeDelete = sut.calculateStreak(from: today) + XCTAssertEqual(beforeDelete.streak, 2) + + sut.deleteAllEntries(forDate: yesterday) + + let afterDelete = sut.calculateStreak(from: today) + XCTAssertEqual(afterDelete.streak, 1, "Streak should decrease after deleting a day") + } +} diff --git a/FeelsTests/VoteLogicsTests.swift b/FeelsTests/VoteLogicsTests.swift new file mode 100644 index 0000000..fcf2775 --- /dev/null +++ b/FeelsTests/VoteLogicsTests.swift @@ -0,0 +1,157 @@ +// +// VoteLogicsTests.swift +// FeelsTests +// +// Tests for ShowBasedOnVoteLogics vote status and timing. +// + +import XCTest +import SwiftData +@testable import Feels + +@MainActor +final class VoteLogicsTests: XCTestCase { + + // MARK: - Phase 7: passedTodaysVotingUnlock Tests + + func test_passedVotingUnlock_beforeTime() { + // Voting unlock set to 6:00 PM, current time is 2:00 PM + let voteDate = makeDateWithTime(hour: 18, minute: 0) + let now = makeDateWithTime(hour: 14, minute: 0) + + let result = ShowBasedOnVoteLogics.passedTodaysVotingUnlock(voteDate: voteDate, now: now) + XCTAssertFalse(result, "Should not have passed unlock when current time is before vote time") + } + + func test_passedVotingUnlock_afterTime() { + // Voting unlock set to 6:00 PM, current time is 8:00 PM + let voteDate = makeDateWithTime(hour: 18, minute: 0) + let now = makeDateWithTime(hour: 20, minute: 0) + + let result = ShowBasedOnVoteLogics.passedTodaysVotingUnlock(voteDate: voteDate, now: now) + XCTAssertTrue(result, "Should have passed unlock when current time is after vote time") + } + + func test_passedVotingUnlock_exactTime() { + // Voting unlock set to 6:00 PM, current time is exactly 6:00 PM + let voteDate = makeDateWithTime(hour: 18, minute: 0) + let now = makeDateWithTime(hour: 18, minute: 0) + + let result = ShowBasedOnVoteLogics.passedTodaysVotingUnlock(voteDate: voteDate, now: now) + XCTAssertTrue(result, "Should have passed unlock at exact vote time (uses >=)") + } + + // MARK: - Phase 7: getCurrentVotingDate Tests + + func test_votingDate_today_beforeTime() { + let onboarding = OnboardingData() + onboarding.inputDay = .Today + onboarding.date = makeDateWithTime(hour: 18, minute: 0) + + let now = makeDateWithTime(hour: 14, minute: 0) // before 6pm + let result = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: onboarding, now: now) + + let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: now)! + XCTAssertTrue(Calendar.current.isDate(result, inSameDayAs: yesterday), + "Today mode, before unlock: should return yesterday") + } + + func test_votingDate_today_afterTime() { + let onboarding = OnboardingData() + onboarding.inputDay = .Today + onboarding.date = makeDateWithTime(hour: 18, minute: 0) + + let now = makeDateWithTime(hour: 20, minute: 0) // after 6pm + let result = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: onboarding, now: now) + + XCTAssertTrue(Calendar.current.isDate(result, inSameDayAs: now), + "Today mode, after unlock: should return today") + } + + func test_votingDate_previous_beforeTime() { + let onboarding = OnboardingData() + onboarding.inputDay = .Previous + onboarding.date = makeDateWithTime(hour: 18, minute: 0) + + let now = makeDateWithTime(hour: 14, minute: 0) // before 6pm + let result = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: onboarding, now: now) + + let twoDaysAgo = Calendar.current.date(byAdding: .day, value: -2, to: now)! + XCTAssertTrue(Calendar.current.isDate(result, inSameDayAs: twoDaysAgo), + "Previous mode, before unlock: should return 2 days ago") + } + + func test_votingDate_previous_afterTime() { + let onboarding = OnboardingData() + onboarding.inputDay = .Previous + onboarding.date = makeDateWithTime(hour: 18, minute: 0) + + let now = makeDateWithTime(hour: 20, minute: 0) // after 6pm + let result = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: onboarding, now: now) + + let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: now)! + XCTAssertTrue(Calendar.current.isDate(result, inSameDayAs: yesterday), + "Previous mode, after unlock: should return yesterday") + } + + // MARK: - Phase 7: Vote Needed Tests (exercising the same logic as isMissingCurrentVote) + // Note: isMissingCurrentVote uses DataController.shared (singleton) internally, + // so we test the equivalent logic using a testable DataController instance. + + func test_voteNeeded_noEntry() { + let sut = makeTestDataController() + let onboarding = OnboardingData() + onboarding.inputDay = .Today + onboarding.date = makeDateWithTime(hour: 0, minute: 1) + + let votingDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: onboarding) + let entry = sut.getEntry(byDate: votingDate.startOfDay) + let isMissing = entry == nil || entry?.mood == .missing + XCTAssertTrue(isMissing, "Should need a vote when no entry exists") + } + + func test_voteNeeded_validEntry() { + let sut = makeTestDataController() + let onboarding = OnboardingData() + onboarding.inputDay = .Today + onboarding.date = makeDateWithTime(hour: 0, minute: 1) + + let votingDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: onboarding) + sut.add(mood: .great, forDate: votingDate, entryType: .listView) + + let entry = sut.getEntry(byDate: votingDate.startOfDay) + let isMissing = entry == nil || entry?.mood == .missing + XCTAssertFalse(isMissing, "Should not need a vote when valid entry exists") + } + + func test_voteNeeded_missingEntry() { + let sut = makeTestDataController() + let onboarding = OnboardingData() + onboarding.inputDay = .Today + onboarding.date = makeDateWithTime(hour: 0, minute: 1) + + let votingDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: onboarding) + sut.add(mood: .missing, forDate: votingDate, entryType: .filledInMissing) + + let entry = sut.getEntry(byDate: votingDate.startOfDay) + let isMissing = entry == nil || entry?.mood == .missing + XCTAssertTrue(isMissing, "Should need a vote when entry is .missing") + } + + // MARK: - Helpers + + private func makeTestDataController() -> DataController { + let schema = Schema([MoodEntryModel.self]) + let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + return DataController(container: try! ModelContainer(for: schema, configurations: [config])) + } + + private func makeDateWithTime(hour: Int, minute: Int) -> Date { + let calendar = Calendar.current + var components = calendar.dateComponents([.year, .month, .day], from: Date()) + components.hour = hour + components.minute = minute + components.second = 0 + return calendar.date(from: components)! + } +} diff --git a/Shared/IAPManager.swift b/Shared/IAPManager.swift index a8f35dd..b9eed57 100644 --- a/Shared/IAPManager.swift +++ b/Shared/IAPManager.swift @@ -36,11 +36,13 @@ class IAPManager: ObservableObject { // MARK: - Debug Toggle /// Set to `true` to bypass all subscription checks and grant full access (for development only) - /// Set to `false` to test trial/subscription behavior in DEBUG builds + /// Togglable at runtime in DEBUG builds via Settings > Debug > Bypass Subscription #if DEBUG - static let bypassSubscription = false + @Published var bypassSubscription: Bool { + didSet { UserDefaults.standard.set(bypassSubscription, forKey: "debug_bypassSubscription") } + } #else - static let bypassSubscription = false + let bypassSubscription = false #endif // MARK: - Constants @@ -96,7 +98,7 @@ class IAPManager: ObservableObject { } var hasFullAccess: Bool { - if Self.bypassSubscription { return true } + if bypassSubscription { return true } switch state { case .subscribed, .billingRetry, .gracePeriod, .inTrial: return true @@ -106,7 +108,7 @@ class IAPManager: ObservableObject { } var shouldShowPaywall: Bool { - if Self.bypassSubscription { return false } + if bypassSubscription { return false } switch state { case .trialExpired, .expired, .revoked: return true @@ -116,6 +118,7 @@ class IAPManager: ObservableObject { } var shouldShowTrialWarning: Bool { + if bypassSubscription { return false } if case .inTrial = state { return true } return false } @@ -137,6 +140,9 @@ class IAPManager: ObservableObject { // MARK: - Initialization init() { + #if DEBUG + self.bypassSubscription = UserDefaults.standard.bool(forKey: "debug_bypassSubscription") + #endif restoreCachedSubscriptionState() updateListenerTask = listenForTransactions() @@ -219,7 +225,7 @@ class IAPManager: ObservableObject { /// Sync subscription status to UserDefaults for widget access private func syncSubscriptionStatusToUserDefaults() { - let accessValue = Self.bypassSubscription ? true : hasFullAccess + let accessValue = bypassSubscription ? true : hasFullAccess GroupUserDefaults.groupDefaults.set(accessValue, forKey: UserDefaultsStore.Keys.hasActiveSubscription.rawValue) } @@ -373,7 +379,7 @@ class IAPManager: ObservableObject { case .unknown: status = "unknown" isSubscribed = false - hasFullAccess = Self.bypassSubscription + hasFullAccess = bypassSubscription willAutoRenew = nil isInGracePeriod = nil trialDaysRemaining = nil diff --git a/Shared/Persisence/DataController.swift b/Shared/Persisence/DataController.swift index 0e990df..190e364 100644 --- a/Shared/Persisence/DataController.swift +++ b/Shared/Persisence/DataController.swift @@ -41,8 +41,8 @@ final class DataController: ObservableObject { return try? modelContext.fetch(descriptor).first } - private init() { - container = SharedModelContainer.createWithFallback(useCloudKit: true) + init(container: ModelContainer? = nil) { + self.container = container ?? SharedModelContainer.createWithFallback(useCloudKit: true) } diff --git a/Shared/Persisence/DataControllerGET.swift b/Shared/Persisence/DataControllerGET.swift index 30cf96c..c849fef 100644 --- a/Shared/Persisence/DataControllerGET.swift +++ b/Shared/Persisence/DataControllerGET.swift @@ -21,7 +21,7 @@ extension DataController { var descriptor = FetchDescriptor( predicate: #Predicate { entry in - entry.forDate >= startDate && entry.forDate <= endDate + entry.forDate >= startDate && entry.forDate < endDate }, sortBy: [SortDescriptor(\.forDate, order: .forward)] ) @@ -66,7 +66,8 @@ extension DataController { return (0, nil) } - let entries = getData(startDate: yearAgo, endDate: votingDate, includedDays: []) + let endOfVotingDay = calendar.date(byAdding: .day, value: 1, to: dayStart) ?? votingDate + let entries = getData(startDate: yearAgo, endDate: endOfVotingDay, includedDays: []) .filter { $0.mood != .missing && $0.mood != .placeholder } guard !entries.isEmpty else { return (0, nil) } diff --git a/Shared/Persisence/DataControllerUPDATE.swift b/Shared/Persisence/DataControllerUPDATE.swift index 67a2170..7d7ded2 100644 --- a/Shared/Persisence/DataControllerUPDATE.swift +++ b/Shared/Persisence/DataControllerUPDATE.swift @@ -16,6 +16,7 @@ extension DataController { } entry.moodValue = mood.rawValue + entry.timestamp = Date() saveAndRunDataListeners() AnalyticsManager.shared.track(.moodUpdated(mood: mood.rawValue)) diff --git a/Shared/Views/SettingsView/SettingsTabView.swift b/Shared/Views/SettingsView/SettingsTabView.swift index 6579647..373aded 100644 --- a/Shared/Views/SettingsView/SettingsTabView.swift +++ b/Shared/Views/SettingsView/SettingsTabView.swift @@ -36,7 +36,7 @@ struct SettingsTabView: View { .padding(.top, 8) // Upgrade Banner (only show if not subscribed) - if !iapManager.isSubscribed { + if !iapManager.isSubscribed && !iapManager.bypassSubscription { UpgradeBannerView( showWhyUpgrade: $showWhyUpgrade, showSubscriptionStore: $showSubscriptionStore, diff --git a/Shared/Views/SettingsView/SettingsView.swift b/Shared/Views/SettingsView/SettingsView.swift index 9f9439d..97012c1 100644 --- a/Shared/Views/SettingsView/SettingsView.swift +++ b/Shared/Views/SettingsView/SettingsView.swift @@ -67,6 +67,7 @@ struct SettingsContentView: View { #if DEBUG // Debug section debugSectionHeader + bypassSubscriptionToggle trialDateButton animationLabButton paywallPreviewButton @@ -211,6 +212,35 @@ struct SettingsContentView: View { .padding(.horizontal, 4) } + private var bypassSubscriptionToggle: some View { + ZStack { + theme.currentTheme.secondaryBGColor + HStack(spacing: 12) { + Image(systemName: "lock.open.fill") + .font(.title2) + .foregroundColor(.green) + .frame(width: 32) + + VStack(alignment: .leading, spacing: 2) { + Text("Bypass Subscription") + .foregroundColor(textColor) + + Text("Hide trial banner & grant full access") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Toggle("", isOn: $iapManager.bypassSubscription) + .labelsHidden() + } + .padding() + } + .fixedSize(horizontal: false, vertical: true) + .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) + } + private var trialDateButton: some View { ZStack { theme.currentTheme.secondaryBGColor @@ -1282,6 +1312,7 @@ struct SettingsView: View { Group { Divider() Text("Test builds only") + Toggle("Bypass Subscription", isOn: $iapManager.bypassSubscription) addTestDataCell clearDB // fixWeekday diff --git a/docs/AppStoreScreens.pxd b/docs/AppStoreScreens.pxd index 8c67886..2c20217 100644 Binary files a/docs/AppStoreScreens.pxd and b/docs/AppStoreScreens.pxd differ