Add debug bypass subscription toggle, tests, and data layer improvements

- Add runtime toggle in Settings (DEBUG only) to bypass subscription/hide trial banner
- IAPManager.bypassSubscription is now a @Published var persisted via UserDefaults
- Hide upgrade banner in SettingsTabView and trial warnings when bypass is enabled
- Add FeelsTests directory with integration tests
- Update DataController, DataControllerGET, DataControllerUPDATE
- Update Xcode project and scheme configuration
- Update localization strings and App Store screen docs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-02-15 17:12:56 -06:00
parent 7c142568be
commit 7639f881da
14 changed files with 1064 additions and 34 deletions

View File

@@ -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 = "<group>"; };
1C0DAB50279DB0FB003B1F21 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Feels/Localizable.xcstrings; sourceTree = "<group>"; };
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 = "<group>"; };
1CDEFBBE2F3B8736006AE6A1 /* Configuration.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = Configuration.storekit; sourceTree = "<group>"; };
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 = "<group>"; };
B8AB4CD73C2B4DC89C6FE84D /* Feels Watch App/Feels Watch App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Feels Watch App/Feels Watch App.entitlements"; sourceTree = "<group>"; };
29E2A2FC314F88244CA946BF /* StreakTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StreakTests.swift; sourceTree = "<group>"; };
5566271983AEDF1D33C34FE6 /* DataControllerCRUDTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DataControllerCRUDTests.swift; sourceTree = "<group>"; };
9CFAE86F485C853DB3239DD9 /* IntegrationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IntegrationTests.swift; sourceTree = "<group>"; };
B60015D02A064FF582E232FD /* Feels Watch AppDebug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Feels Watch App/Feels Watch AppDebug.entitlements"; sourceTree = "<group>"; };
B8AB4CD73C2B4DC89C6FE84D /* Feels Watch App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Feels Watch App/Feels Watch App.entitlements"; sourceTree = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
1C0009922EE938FC009C9ED5 /* FeelsWidget2 */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = FeelsWidget2; sourceTree = "<group>"; };
579031D619ED4B989145EEB1 /* Feels Watch App */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = "Feels Watch App"; sourceTree = "<group>"; };
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 = "<group>";
};
1C0009922EE938FC009C9ED5 /* FeelsWidget2 */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
explicitFileTypes = {
};
explicitFolders = (
);
path = FeelsWidget2;
sourceTree = "<group>";
};
579031D619ED4B989145EEB1 /* Feels Watch App */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
explicitFileTypes = {
};
explicitFolders = (
);
path = "Feels Watch App";
sourceTree = "<group>";
};
/* 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 = "<group>";
};
@@ -228,6 +287,7 @@
1CD90B02278C7DE0001C4FEA /* Tests iOS.xctest */,
1CD90B0E278C7DE0001C4FEA /* Tests macOS.xctest */,
1CD90B45278C7E7A001C4FEA /* FeelsWidgetExtension.appex */,
DA0D74ACDD741CFA1F14F50F /* FeelsTests.xctest */,
);
name = Products;
sourceTree = "<group>";
@@ -265,10 +325,31 @@
1CD90B6B278C7F78001C4FEA /* CloudKit.framework */,
1CD90B47278C7E7A001C4FEA /* WidgetKit.framework */,
1CD90B49278C7E7A001C4FEA /* SwiftUI.framework */,
88F4C25CA0D11FB136B0B8A6 /* iOS */,
);
name = Frameworks;
sourceTree = "<group>";
};
38D005587E22737DC6291955 /* FeelsTests */ = {
isa = PBXGroup;
children = (
5566271983AEDF1D33C34FE6 /* DataControllerCRUDTests.swift */,
9CFAE86F485C853DB3239DD9 /* IntegrationTests.swift */,
29E2A2FC314F88244CA946BF /* StreakTests.swift */,
DD717F91BD65382B7DDFE3C4 /* VoteLogicsTests.swift */,
);
name = FeelsTests;
path = FeelsTests;
sourceTree = "<group>";
};
88F4C25CA0D11FB136B0B8A6 /* iOS */ = {
isa = PBXGroup;
children = (
F4D304CD05CC7C662CCD7DCB /* Foundation.framework */,
);
name = iOS;
sourceTree = "<group>";
};
/* 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 */

View File

@@ -32,9 +32,9 @@
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1CD90B01278C7DE0001C4FEA"
BuildableName = "Tests iOS.xctest"
BlueprintName = "Tests iOS"
BlueprintIdentifier = "B375A511826E3AB53E2CF51A"
BuildableName = "FeelsTests.xctest"
BlueprintName = "FeelsTests"
ReferencedContainer = "container:Feels.xcodeproj">
</BuildableReference>
</TestableReference>

View File

@@ -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,

View File

@@ -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)!
}

View File

@@ -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")
}
}

View File

@@ -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")
}
}

View File

@@ -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)!
}
}

View File

@@ -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

View File

@@ -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)
}

View File

@@ -21,7 +21,7 @@ extension DataController {
var descriptor = FetchDescriptor<MoodEntryModel>(
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) }

View File

@@ -16,6 +16,7 @@ extension DataController {
}
entry.moodValue = mood.rawValue
entry.timestamp = Date()
saveAndRunDataListeners()
AnalyticsManager.shared.track(.moodUpdated(mood: mood.rawValue))

View File

@@ -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,

View File

@@ -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

Binary file not shown.