diff --git a/Feels.xcodeproj/project.pbxproj b/Feels.xcodeproj/project.pbxproj index 0c816ec..e7dde9b 100644 --- a/Feels.xcodeproj/project.pbxproj +++ b/Feels.xcodeproj/project.pbxproj @@ -30,16 +30,19 @@ 2EE4D94530F6BF39B26FB4D4 /* DayScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 427CD9C91D43AB6A0302B4DD /* DayScreen.swift */; }; 343D472E5524E2E8ED59A7CC /* DateLocaleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF843FEBE18F8FF570CC4CCB /* DateLocaleTests.swift */; }; 39C43652C41F5459788A604D /* SpanishLocalizationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31C2982F0B879A0C57273F0E /* SpanishLocalizationTests.swift */; }; + 3CEA4027122C070775D4B626 /* ShareNoDataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEFCBB5FD6C7ACF4C7FC93F1 /* ShareNoDataTests.swift */; }; 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 */; }; 624CA4AB557BB0C30A0E2198 /* LongTranslationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA7E6C56A47EB49419BFA77C /* LongTranslationTests.swift */; }; 6F9C9C4B50CF8C1769171FF9 /* NoteEditTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 469470483072085BE9E04E12 /* NoteEditTests.swift */; }; 756B9857B0657D2DB2D6D4E2 /* AppResumeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0359E1D32D936859E5A0C9F3 /* AppResumeTests.swift */; }; + 809786A73B85C3E9817B2874 /* InsightsPullToRefreshTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FCEB60831D3AC7F1164BCF9 /* InsightsPullToRefreshTests.swift */; }; 85EF4702AE378AB3198E67D3 /* AccessibilityTextSizeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C033EE00E7E7B3448FB862DA /* AccessibilityTextSizeTests.swift */; }; 8F39BFEBFC387DBDA42CBDA5 /* OnboardingVotingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37D5EECC086A9E7F469B5873 /* OnboardingVotingTests.swift */; }; 92C1523E0398F866DB4CA027 /* SettingsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 881CA8B21231D67DED575502 /* SettingsScreen.swift */; }; 9559409B5AEEAB40EBCB6AF9 /* VoteLogicsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD717F91BD65382B7DDFE3C4 /* VoteLogicsTests.swift */; }; + 9E3935A182AFFC51879BF014 /* InsightsCollapseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C5BA5AC63C8CC7D72D0D80F /* InsightsCollapseTests.swift */; }; A018FE95582C04ED0F1806DC /* BaseUITestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29CE4110A0D8FBBAD7F92BDF /* BaseUITestCase.swift */; }; A1B2C3D400000000C9D0E1F2 /* NoteEditorScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6A7B8C9D0E1F2 /* NoteEditorScreen.swift */; }; A1B2C3D4E5F607080910ABCD /* DayViewViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4E5F60708091011ABCDE001 /* DayViewViewModelTests.swift */; }; @@ -160,6 +163,7 @@ 29CE4110A0D8FBBAD7F92BDF /* BaseUITestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseUITestCase.swift; sourceTree = ""; }; 29E2A2FC314F88244CA946BF /* StreakTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StreakTests.swift; sourceTree = ""; }; 2C8D04ACF01F539EA572EEB8 /* ReduceMotionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReduceMotionTests.swift; sourceTree = ""; }; + 2FCEB60831D3AC7F1164BCF9 /* InsightsPullToRefreshTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = InsightsPullToRefreshTests.swift; sourceTree = ""; }; 31C2982F0B879A0C57273F0E /* SpanishLocalizationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SpanishLocalizationTests.swift; sourceTree = ""; }; 35AF32CC88B36CDFCB338F2C /* TrialExpirationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TrialExpirationTests.swift; sourceTree = ""; }; 37D5EECC086A9E7F469B5873 /* OnboardingVotingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OnboardingVotingTests.swift; sourceTree = ""; }; @@ -171,6 +175,7 @@ 7E35564DEA72EB6F8447CDAA /* EntryDetailScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntryDetailScreen.swift; sourceTree = ""; }; 8114D2CE12EC5392371BB415 /* DarkModeStylesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DarkModeStylesTests.swift; sourceTree = ""; }; 881CA8B21231D67DED575502 /* SettingsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreen.swift; sourceTree = ""; }; + 8C5BA5AC63C8CC7D72D0D80F /* InsightsCollapseTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = InsightsCollapseTests.swift; sourceTree = ""; }; 9CFAE86F485C853DB3239DD9 /* IntegrationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IntegrationTests.swift; sourceTree = ""; }; A1B2C3D4E5F6A7B8C9D0E1F2 /* NoteEditorScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteEditorScreen.swift; sourceTree = ""; }; A3B4C5D6E7F8A9B0C1D2E3F4 /* DataPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataPersistenceTests.swift; sourceTree = ""; }; @@ -208,6 +213,7 @@ E5F6A7B8C9D0E1F2A3B4C5D6 /* EmptyStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyStateTests.swift; sourceTree = ""; }; E7F8A9B0C1D2E3F4A5B6C7D8 /* PremiumCustomizationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PremiumCustomizationTests.swift; sourceTree = ""; }; EE55555555555555EEEEEEEE /* SettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsTests.swift; sourceTree = ""; }; + EEFCBB5FD6C7ACF4C7FC93F1 /* ShareNoDataTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareNoDataTests.swift; sourceTree = ""; }; F2A3B4C5D6E7F8A9B0C1D2E3 /* StabilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StabilityTests.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; }; F5A135CC76572BAD0445B0DD /* HighContrastTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HighContrastTests.swift; sourceTree = ""; }; @@ -460,6 +466,9 @@ 37D5EECC086A9E7F469B5873 /* OnboardingVotingTests.swift */, DF843FEBE18F8FF570CC4CCB /* DateLocaleTests.swift */, DA7E6C56A47EB49419BFA77C /* LongTranslationTests.swift */, + 8C5BA5AC63C8CC7D72D0D80F /* InsightsCollapseTests.swift */, + 2FCEB60831D3AC7F1164BCF9 /* InsightsPullToRefreshTests.swift */, + EEFCBB5FD6C7ACF4C7FC93F1 /* ShareNoDataTests.swift */, ); path = "Tests iOS"; sourceTree = ""; @@ -875,6 +884,9 @@ 8F39BFEBFC387DBDA42CBDA5 /* OnboardingVotingTests.swift in Sources */, 343D472E5524E2E8ED59A7CC /* DateLocaleTests.swift in Sources */, 624CA4AB557BB0C30A0E2198 /* LongTranslationTests.swift in Sources */, + 9E3935A182AFFC51879BF014 /* InsightsCollapseTests.swift in Sources */, + 809786A73B85C3E9817B2874 /* InsightsPullToRefreshTests.swift in Sources */, + 3CEA4027122C070775D4B626 /* ShareNoDataTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Shared/AccessibilityIdentifiers.swift b/Shared/AccessibilityIdentifiers.swift index 3a78c06..d612b3f 100644 --- a/Shared/AccessibilityIdentifiers.swift +++ b/Shared/AccessibilityIdentifiers.swift @@ -126,6 +126,7 @@ enum AccessibilityID { // MARK: - Month View enum MonthView { static let grid = "month_grid" + static let shareButton = "month_share_button" } // MARK: - Year View diff --git a/Shared/Views/InsightsView/InsightsView.swift b/Shared/Views/InsightsView/InsightsView.swift index ed0d690..4f8c085 100644 --- a/Shared/Views/InsightsView/InsightsView.swift +++ b/Shared/Views/InsightsView/InsightsView.swift @@ -66,6 +66,7 @@ struct InsightsView: View { imagePack: imagePack, colorScheme: colorScheme ) + .accessibilityIdentifier(AccessibilityID.Insights.monthSection) // This Year Section InsightsSectionView( @@ -78,6 +79,7 @@ struct InsightsView: View { imagePack: imagePack, colorScheme: colorScheme ) + .accessibilityIdentifier(AccessibilityID.Insights.yearSection) // All Time Section InsightsSectionView( @@ -90,6 +92,7 @@ struct InsightsView: View { imagePack: imagePack, colorScheme: colorScheme ) + .accessibilityIdentifier(AccessibilityID.Insights.allTimeSection) } .padding(.vertical) .padding(.bottom, 100) diff --git a/Shared/Views/MonthView/MonthView.swift b/Shared/Views/MonthView/MonthView.swift index e1ccae7..6acb153 100644 --- a/Shared/Views/MonthView/MonthView.swift +++ b/Shared/Views/MonthView/MonthView.swift @@ -603,6 +603,7 @@ struct MonthCard: View, Equatable { } .buttonStyle(.plain) .accessibilityLabel("Share \(Random.monthName(fromMonthInt: month)) \(String(year)) mood data") + .accessibilityIdentifier(AccessibilityID.MonthView.shareButton) } .padding(.horizontal, 16) .padding(.vertical, 12) diff --git a/Tests iOS/Helpers/WaitHelpers.swift b/Tests iOS/Helpers/WaitHelpers.swift index 6a75093..fc2d73f 100644 --- a/Tests iOS/Helpers/WaitHelpers.swift +++ b/Tests iOS/Helpers/WaitHelpers.swift @@ -84,6 +84,9 @@ enum UITestID { enum Insights { static let header = "insights_header" + static let monthSection = "insights_month_section" + static let yearSection = "insights_year_section" + static let allTimeSection = "insights_all_time_section" } enum Year { @@ -97,6 +100,7 @@ enum UITestID { enum Month { static let grid = "month_grid" + static let shareButton = "month_share_button" } } diff --git a/Tests iOS/InsightsCollapseTests.swift b/Tests iOS/InsightsCollapseTests.swift new file mode 100644 index 0000000..aabf89a --- /dev/null +++ b/Tests iOS/InsightsCollapseTests.swift @@ -0,0 +1,58 @@ +// +// InsightsCollapseTests.swift +// Tests iOS +// +// TC-046: Collapse/expand insight sections. +// + +import XCTest + +final class InsightsCollapseTests: BaseUITestCase { + override var seedFixture: String? { "week_of_moods" } + override var bypassSubscription: Bool { true } + + /// TC-046: Tapping a section header collapses/expands that section. + func testInsights_CollapseExpandSections() { + let tabBar = TabBarScreen(app: app) + tabBar.tapInsights() + + // Verify Insights header loads + let header = app.element(UITestID.Insights.header) + XCTAssertTrue( + header.waitForExistence(timeout: 8), + "Insights header should be visible" + ) + + captureScreenshot(name: "insights_initial") + + // Find the "This Month" section header text and tap to collapse + // Note: the text is inside a Button, so we use coordinate tap fallback + let monthTitle = app.staticTexts["This Month"].firstMatch + XCTAssertTrue( + monthTitle.waitForExistence(timeout: 5), + "This Month section title should exist" + ) + + monthTitle.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() + + // Brief wait for animation + _ = app.waitForExistence(timeout: 1) + + captureScreenshot(name: "insights_month_collapsed") + + // Tap again to expand + monthTitle.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() + + _ = app.waitForExistence(timeout: 1) + + captureScreenshot(name: "insights_month_expanded") + + // Also test "This Year" section + let yearTitle = app.staticTexts["This Year"].firstMatch + if yearTitle.waitForExistence(timeout: 3) { + yearTitle.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() + _ = app.waitForExistence(timeout: 1) + captureScreenshot(name: "insights_year_collapsed") + } + } +} diff --git a/Tests iOS/InsightsPullToRefreshTests.swift b/Tests iOS/InsightsPullToRefreshTests.swift new file mode 100644 index 0000000..cf11345 --- /dev/null +++ b/Tests iOS/InsightsPullToRefreshTests.swift @@ -0,0 +1,51 @@ +// +// InsightsPullToRefreshTests.swift +// Tests iOS +// +// TC-047: Pull to refresh on Insights tab. +// + +import XCTest + +final class InsightsPullToRefreshTests: BaseUITestCase { + override var seedFixture: String? { "week_of_moods" } + override var bypassSubscription: Bool { true } + + /// TC-047: Pull-to-refresh gesture on Insights tab does not crash and UI remains functional. + func testInsights_PullToRefresh_NoLayoutCrash() { + let tabBar = TabBarScreen(app: app) + tabBar.tapInsights() + + // Verify Insights header loads + let header = app.element(UITestID.Insights.header) + XCTAssertTrue( + header.waitForExistence(timeout: 8), + "Insights header should be visible" + ) + + captureScreenshot(name: "insights_before_refresh") + + // Perform pull-to-refresh gesture (drag from top area downward) + let start = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.3)) + let end = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.8)) + start.press(forDuration: 0.1, thenDragTo: end) + + // Wait for refresh to settle + _ = app.waitForExistence(timeout: 3) + + captureScreenshot(name: "insights_after_refresh") + + // Verify UI is still functional — header should still be there + XCTAssertTrue( + header.waitForExistence(timeout: 5), + "Insights header should still be visible after pull-to-refresh" + ) + + // Verify sections are still present + let monthTitle = app.staticTexts["This Month"].firstMatch + XCTAssertTrue( + monthTitle.waitForExistence(timeout: 5), + "This Month section should still be visible after pull-to-refresh" + ) + } +} diff --git a/Tests iOS/ShareNoDataTests.swift b/Tests iOS/ShareNoDataTests.swift new file mode 100644 index 0000000..ebe8f5c --- /dev/null +++ b/Tests iOS/ShareNoDataTests.swift @@ -0,0 +1,81 @@ +// +// ShareNoDataTests.swift +// Tests iOS +// +// TC-119: Share with no mood data — verifies graceful behavior. +// + +import XCTest + +final class ShareNoDataTests: BaseUITestCase { + override var seedFixture: String? { "empty" } + override var bypassSubscription: Bool { true } + + /// TC-119: With no mood data, Year view share button is absent or sharing handles empty state. + func testShare_NoData_GracefulBehavior() { + let tabBar = TabBarScreen(app: app) + tabBar.tapYear() + + // Wait for year view to load + _ = app.waitForExistence(timeout: 3) + + captureScreenshot(name: "share_no_data_year") + + // With no mood data, there should be no year card share button + let shareButton = app.element(UITestID.Year.shareButton) + let shareExists = shareButton.waitForExistence(timeout: 3) + + if shareExists { + // If the share button exists despite no data, tap it and verify + // the sharing picker handles empty state gracefully + shareButton.tapWhenReady() + + _ = app.waitForExistence(timeout: 2) + + captureScreenshot(name: "share_no_data_picker") + + // Look for "No designs available" text or a valid picker + let noDesigns = app.staticTexts["No designs available"].firstMatch + let exitButton = app.buttons["Exit"].firstMatch + let pickerPresent = noDesigns.waitForExistence(timeout: 3) || + exitButton.waitForExistence(timeout: 3) + + // Either the picker shows empty state or renders normally + // Both are acceptable — the key is no crash + if exitButton.exists { + exitButton.tap() + } + } + + // Navigate to Month view and check share button there too + tabBar.tapMonth() + _ = app.waitForExistence(timeout: 3) + + captureScreenshot(name: "share_no_data_month") + + let monthShareButton = app.element(UITestID.Month.shareButton) + let monthShareExists = monthShareButton.waitForExistence(timeout: 3) + + // With empty data, month share button should be absent + // or if present, should handle gracefully (no crash) + if monthShareExists { + monthShareButton.tapWhenReady() + _ = app.waitForExistence(timeout: 2) + captureScreenshot(name: "share_no_data_month_picker") + + let exitButton = app.buttons["Exit"].firstMatch + if exitButton.waitForExistence(timeout: 3) { + exitButton.tap() + } + } + + // Final verification: app is still responsive + tabBar.tapDay() + let emptyState = app.element(UITestID.Day.emptyStateNoData) + let moodHeader = app.element(UITestID.Day.moodHeader) + XCTAssertTrue( + emptyState.waitForExistence(timeout: 5) || moodHeader.waitForExistence(timeout: 2), + "App should remain functional after share-with-no-data flow" + ) + } +} diff --git a/docs/Feels_QA_Test_Plan.xlsx b/docs/Feels_QA_Test_Plan.xlsx index 7d3b9b0..c22f4fb 100644 Binary files a/docs/Feels_QA_Test_Plan.xlsx and b/docs/Feels_QA_Test_Plan.xlsx differ