diff --git a/Feels.xcodeproj/project.pbxproj b/Feels.xcodeproj/project.pbxproj index 15fe5df..7ab4e60 100644 --- a/Feels.xcodeproj/project.pbxproj +++ b/Feels.xcodeproj/project.pbxproj @@ -49,6 +49,12 @@ E5F6A7B800000000A3B4C5D6 /* EmptyStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5F6A7B8C9D0E1F2A3B4C5D6 /* EmptyStateTests.swift */; }; F6A7B8C900000000B4C5D6E7 /* EntryDeleteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6A7B8C9D0E1F2A3B4C5D6E7 /* EntryDeleteTests.swift */; }; A7B8C9D000000000C5D6E7F8 /* NotesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7B8C9D0E1F2A3B4C5D6E7F8 /* NotesTests.swift */; }; + F75470AA2BA1E9EFF8F5265A /* LocalizationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17DC4C498A1185DC831F4593 /* LocalizationTests.swift */; }; + E3482DB0421C12E11517BDC8 /* TrialBannerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21CD463209E0909393545D62 /* TrialBannerTests.swift */; }; + A4B459F8CE7F5534DE4FADCA /* DarkModeStylesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8114D2CE12EC5392371BB415 /* DarkModeStylesTests.swift */; }; + 1AB245144C89927264D16645 /* InsightsEmptyStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6988985985DE9C29CFDFA96 /* InsightsEmptyStateTests.swift */; }; + 756B9857B0657D2DB2D6D4E2 /* AppResumeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0359E1D32D936859E5A0C9F3 /* AppResumeTests.swift */; }; + 6F9C9C4B50CF8C1769171FF9 /* NoteEditTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 469470483072085BE9E04E12 /* NoteEditTests.swift */; }; B8C9D0E100000000D6E7F8A9 /* MonthViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8C9D0E1F2A3B4C5D6E7F8A9 /* MonthViewTests.swift */; }; C9D0E1F200000000E7F8A9B0 /* SettingsActionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9D0E1F2A3B4C5D6E7F8A9B0 /* SettingsActionTests.swift */; }; D0E1F2A300000000F8A9B0C1 /* CustomizationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E1F2A3B4C5D6E7F8A9B0C1 /* CustomizationTests.swift */; }; @@ -161,6 +167,12 @@ E5F6A7B8C9D0E1F2A3B4C5D6 /* EmptyStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyStateTests.swift; sourceTree = ""; }; F6A7B8C9D0E1F2A3B4C5D6E7 /* EntryDeleteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntryDeleteTests.swift; sourceTree = ""; }; A7B8C9D0E1F2A3B4C5D6E7F8 /* NotesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotesTests.swift; sourceTree = ""; }; + 17DC4C498A1185DC831F4593 /* LocalizationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizationTests.swift; sourceTree = ""; }; + 21CD463209E0909393545D62 /* TrialBannerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrialBannerTests.swift; sourceTree = ""; }; + 8114D2CE12EC5392371BB415 /* DarkModeStylesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DarkModeStylesTests.swift; sourceTree = ""; }; + A6988985985DE9C29CFDFA96 /* InsightsEmptyStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsightsEmptyStateTests.swift; sourceTree = ""; }; + 0359E1D32D936859E5A0C9F3 /* AppResumeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppResumeTests.swift; sourceTree = ""; }; + 469470483072085BE9E04E12 /* NoteEditTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteEditTests.swift; sourceTree = ""; }; B8C9D0E1F2A3B4C5D6E7F8A9 /* MonthViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MonthViewTests.swift; sourceTree = ""; }; C9D0E1F2A3B4C5D6E7F8A9B0 /* SettingsActionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsActionTests.swift; sourceTree = ""; }; D0E1F2A3B4C5D6E7F8A9B0C1 /* CustomizationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomizationTests.swift; sourceTree = ""; }; @@ -354,6 +366,12 @@ E5F6A7B8C9D0E1F2A3B4C5D6 /* EmptyStateTests.swift */, F6A7B8C9D0E1F2A3B4C5D6E7 /* EntryDeleteTests.swift */, A7B8C9D0E1F2A3B4C5D6E7F8 /* NotesTests.swift */, + 17DC4C498A1185DC831F4593 /* LocalizationTests.swift */, + 21CD463209E0909393545D62 /* TrialBannerTests.swift */, + 8114D2CE12EC5392371BB415 /* DarkModeStylesTests.swift */, + A6988985985DE9C29CFDFA96 /* InsightsEmptyStateTests.swift */, + 0359E1D32D936859E5A0C9F3 /* AppResumeTests.swift */, + 469470483072085BE9E04E12 /* NoteEditTests.swift */, B8C9D0E1F2A3B4C5D6E7F8A9 /* MonthViewTests.swift */, C9D0E1F2A3B4C5D6E7F8A9B0 /* SettingsActionTests.swift */, D0E1F2A3B4C5D6E7F8A9B0C1 /* CustomizationTests.swift */, @@ -749,6 +767,12 @@ E5F6A7B800000000A3B4C5D6 /* EmptyStateTests.swift in Sources */, F6A7B8C900000000B4C5D6E7 /* EntryDeleteTests.swift in Sources */, A7B8C9D000000000C5D6E7F8 /* NotesTests.swift in Sources */, + F75470AA2BA1E9EFF8F5265A /* LocalizationTests.swift in Sources */, + E3482DB0421C12E11517BDC8 /* TrialBannerTests.swift in Sources */, + A4B459F8CE7F5534DE4FADCA /* DarkModeStylesTests.swift in Sources */, + 1AB245144C89927264D16645 /* InsightsEmptyStateTests.swift in Sources */, + 756B9857B0657D2DB2D6D4E2 /* AppResumeTests.swift in Sources */, + 6F9C9C4B50CF8C1769171FF9 /* NoteEditTests.swift in Sources */, B8C9D0E100000000D6E7F8A9 /* MonthViewTests.swift in Sources */, C9D0E1F200000000E7F8A9B0 /* SettingsActionTests.swift in Sources */, D0E1F2A300000000F8A9B0C1 /* CustomizationTests.swift in Sources */, diff --git a/Tests iOS/AppResumeTests.swift b/Tests iOS/AppResumeTests.swift new file mode 100644 index 0000000..58ec465 --- /dev/null +++ b/Tests iOS/AppResumeTests.swift @@ -0,0 +1,34 @@ +// +// AppResumeTests.swift +// Tests iOS +// +// TC-153: App resumes correctly from background. +// + +import XCTest + +final class AppResumeTests: BaseUITestCase { + override var seedFixture: String? { "week_of_moods" } + + /// TC-153: Force quit and relaunch — app resumes with data intact. + func testAppResumes_FromBackground() { + // Verify initial state + let tabBar = TabBarScreen(app: app) + tabBar.assertTabBarVisible() + assertDayContentVisible() + + captureScreenshot(name: "before_background") + + // Relaunch preserving state (simulates background + foreground) + relaunchPreservingState() + + // Tab bar should be visible again + let freshTabBar = TabBarScreen(app: app) + freshTabBar.assertTabBarVisible() + + // Day content should still be visible (data persisted) + assertDayContentVisible() + + captureScreenshot(name: "after_resume") + } +} diff --git a/Tests iOS/DarkModeStylesTests.swift b/Tests iOS/DarkModeStylesTests.swift new file mode 100644 index 0000000..c32d177 --- /dev/null +++ b/Tests iOS/DarkModeStylesTests.swift @@ -0,0 +1,51 @@ +// +// DarkModeStylesTests.swift +// Tests iOS +// +// TC-022: Day view styles render correctly in dark mode. +// + +import XCTest + +final class DarkModeStylesTests: BaseUITestCase { + override var seedFixture: String? { "single_mood" } + + /// TC-022: Day view styles render without crash in dark mode. + func testDayViewStyles_DarkMode_NoCrash() { + let tabBar = TabBarScreen(app: app) + let customizeScreen = CustomizeScreen(app: app) + + // First, switch to dark mode via the theme mode selector + let settingsScreen = tabBar.tapSettings() + settingsScreen.assertVisible() + settingsScreen.tapCustomizeTab() + + // Try to select the "Dark" theme mode + let darkButton = customizeScreen.themeButton(named: "Dark") + if darkButton.waitForExistence(timeout: 3) || app.swipeUntilExists(darkButton, direction: .up, maxSwipes: 3) { + darkButton.tapWhenReady() + } + + // Navigate to Day tab to verify dark mode renders correctly + tabBar.tapDay() + assertDayContentVisible() + + captureScreenshot(name: "day_view_dark_mode_default_style") + + // Try a few different day view styles in dark mode + let sampleStyles = ["Classic", "Neon", "Glass"] + + for style in sampleStyles { + let settings = tabBar.tapSettings() + settings.assertVisible() + settings.tapCustomizeTab() + + customizeScreen.selectDayViewStyle(style) + + tabBar.tapDay() + assertDayContentVisible() + } + + captureScreenshot(name: "day_view_dark_mode_styles_completed") + } +} diff --git a/Tests iOS/InsightsEmptyStateTests.swift b/Tests iOS/InsightsEmptyStateTests.swift new file mode 100644 index 0000000..6c187bd --- /dev/null +++ b/Tests iOS/InsightsEmptyStateTests.swift @@ -0,0 +1,50 @@ +// +// InsightsEmptyStateTests.swift +// Tests iOS +// +// TC-043: Insights with no data shows empty state message. +// + +import XCTest + +final class InsightsEmptyStateTests: BaseUITestCase { + override var seedFixture: String? { "empty" } + + /// TC-043: Navigate to Insights with no data — should show "No Data Yet" or similar message. + func testInsights_EmptyState_ShowsNoDataMessage() { + let tabBar = TabBarScreen(app: app) + tabBar.tapInsights() + + // Wait for insights content to load + let insightsHeader = app.element(UITestID.Insights.header) + XCTAssertTrue( + insightsHeader.waitForExistence(timeout: 10), + "Insights header should be visible" + ) + + captureScreenshot(name: "insights_empty_state") + + // Look for empty state text — either "No Data Yet" or "AI Unavailable" + // (Both are valid on simulator with no data) + let noDataText = app.staticTexts.matching( + NSPredicate(format: "label CONTAINS[cd] %@", "No Data") + ).firstMatch + let aiUnavailable = app.staticTexts.matching( + NSPredicate(format: "label CONTAINS[cd] %@", "Unavailable") + ).firstMatch + let startLogging = app.staticTexts.matching( + NSPredicate(format: "label CONTAINS[cd] %@", "Start logging") + ).firstMatch + + let hasEmptyMessage = noDataText.waitForExistence(timeout: 10) + || aiUnavailable.waitForExistence(timeout: 3) + || startLogging.waitForExistence(timeout: 3) + + XCTAssertTrue( + hasEmptyMessage, + "Insights should show an empty state or unavailable message when no data exists" + ) + + captureScreenshot(name: "insights_empty_message") + } +} diff --git a/Tests iOS/LocalizationTests.swift b/Tests iOS/LocalizationTests.swift new file mode 100644 index 0000000..35c9269 --- /dev/null +++ b/Tests iOS/LocalizationTests.swift @@ -0,0 +1,47 @@ +// +// LocalizationTests.swift +// Tests iOS +// +// TC-136: English strings display correctly. +// + +import XCTest + +final class LocalizationTests: BaseUITestCase { + override var seedFixture: String? { "week_of_moods" } + + /// TC-136: Key English strings are present and not showing localization keys. + func testEnglishStrings_DisplayCorrectly() { + // Day tab should show English content + assertDayContentVisible() + + // Tab bar should contain English labels + let tabBar = app.tabBars.firstMatch + XCTAssertTrue(tabBar.waitForExistence(timeout: 5), "Tab bar should exist") + + captureScreenshot(name: "localization_day_tab") + + // Navigate to Settings and verify English header + let tabBarScreen = TabBarScreen(app: app) + let settingsScreen = tabBarScreen.tapSettings() + settingsScreen.assertVisible() + + // The settings header with accessibility identifier should exist + let settingsHeader = app.element(UITestID.Settings.header) + XCTAssertTrue( + settingsHeader.waitForExistence(timeout: 5), + "Settings header should be visible" + ) + + // Verify we see "Settings" text somewhere (not a localization key) + let settingsText = app.staticTexts.matching( + NSPredicate(format: "label == %@", "Settings") + ).firstMatch + XCTAssertTrue( + settingsText.waitForExistence(timeout: 3), + "Settings title should display in English (not localization key)" + ) + + captureScreenshot(name: "localization_settings_english") + } +} diff --git a/Tests iOS/NoteEditTests.swift b/Tests iOS/NoteEditTests.swift new file mode 100644 index 0000000..a1a954b --- /dev/null +++ b/Tests iOS/NoteEditTests.swift @@ -0,0 +1,122 @@ +// +// NoteEditTests.swift +// Tests iOS +// +// TC-133: Edit an existing note. +// TC-134: Long note (>1000 characters). +// + +import XCTest + +final class NoteEditTests: BaseUITestCase { + override var seedFixture: String? { "single_mood" } + + // MARK: - Helpers + + /// Opens the note editor for the first entry and types the given text. + /// Returns the entry detail and note editor screens for further assertions. + private func addNote(_ text: String) -> (detail: EntryDetailScreen, editor: NoteEditorScreen) { + guard app.firstEntryRow.waitForExistence(timeout: 8) else { + XCTFail("No entry row found") + return (EntryDetailScreen(app: app), NoteEditorScreen(app: app)) + } + app.firstEntryRow.tapWhenReady() + + let detail = EntryDetailScreen(app: app) + detail.assertVisible() + + // Open note editor + let noteArea = app.element(UITestID.EntryDetail.noteArea) + if noteArea.waitForExistence(timeout: 3) { + noteArea.tapWhenReady() + } else { + let noteButton = app.element(UITestID.EntryDetail.noteButton) + noteButton.tapWhenReady() + } + + let editor = NoteEditorScreen(app: app) + editor.assertVisible() + editor.clearAndTypeNote(text) + editor.save() + editor.assertDismissed() + + return (detail, editor) + } + + /// Re-opens the note editor from the current entry detail view. + private func reopenNoteEditor() -> NoteEditorScreen { + let noteArea = app.element(UITestID.EntryDetail.noteArea) + if noteArea.waitForExistence(timeout: 3) { + noteArea.tapWhenReady() + } else { + let noteButton = app.element(UITestID.EntryDetail.noteButton) + noteButton.tapWhenReady() + } + + let editor = NoteEditorScreen(app: app) + editor.assertVisible() + return editor + } + + // MARK: - Tests + + /// TC-133: Edit an existing note — add note, reopen, change text, verify new text. + func testEditNote_ExistingEntry() { + // Step 1: Add initial note + let (detail, _) = addNote("Original note text") + + // Verify initial note is visible + let originalText = app.staticTexts.matching( + NSPredicate(format: "label CONTAINS %@", "Original note text") + ).firstMatch + XCTAssertTrue( + originalText.waitForExistence(timeout: 5), + "Original note should be visible" + ) + + captureScreenshot(name: "note_original") + + // Step 2: Reopen and edit the note + let editor = reopenNoteEditor() + editor.clearAndTypeNote("Updated note text") + editor.save() + editor.assertDismissed() + + // Step 3: Verify edited note is shown + let updatedText = app.staticTexts.matching( + NSPredicate(format: "label CONTAINS %@", "Updated note text") + ).firstMatch + XCTAssertTrue( + updatedText.waitForExistence(timeout: 5), + "Updated note text should be visible after editing" + ) + + captureScreenshot(name: "note_edited") + + detail.dismiss() + detail.assertDismissed() + } + + /// TC-134: Add a long note (>1000 characters). + func testLongNote_Over1000Characters() { + // Generate a long string > 1000 chars + let longText = String(repeating: "This is a test note. ", count: 55) // ~1155 chars + + // Add the long note + let (detail, _) = addNote(longText) + + // Verify some portion of the note is visible + let noteSnippet = app.staticTexts.matching( + NSPredicate(format: "label CONTAINS %@", "This is a test note") + ).firstMatch + XCTAssertTrue( + noteSnippet.waitForExistence(timeout: 5), + "Long note text should be visible after saving" + ) + + captureScreenshot(name: "note_long_saved") + + detail.dismiss() + detail.assertDismissed() + } +} diff --git a/Tests iOS/TrialBannerTests.swift b/Tests iOS/TrialBannerTests.swift new file mode 100644 index 0000000..89c5045 --- /dev/null +++ b/Tests iOS/TrialBannerTests.swift @@ -0,0 +1,92 @@ +// +// TrialBannerTests.swift +// Tests iOS +// +// TC-033: Trial warning banner shown. +// TC-076: Fresh install starts 30-day trial. +// TC-080: Trial banner hidden with bypass (DEBUG). +// + +import XCTest + +/// Tests that verify trial banner visibility based on subscription state. +final class TrialBannerTests: BaseUITestCase { + override var seedFixture: String? { "single_mood" } + + /// TC-076: On fresh install, Settings shows an upgrade banner (indicating trial is active). + func testFreshInstall_ShowsTrialBanner() { + let tabBar = TabBarScreen(app: app) + let settingsScreen = tabBar.tapSettings() + settingsScreen.assertVisible() + + // With default settings (bypassSubscription = true), the banner is hidden. + // We need to launch without bypass to see the banner. + // Re-launch with bypass disabled. + app.terminate() + + let freshApp = XCUIApplication() + var args = ["--ui-testing", "--reset-state", "--disable-animations", "--skip-onboarding", + "-AppleLanguages", "(en)", "-AppleLocale", "en_US"] + // Do NOT add --bypass-subscription + freshApp.launchArguments = args + if let fixture = seedFixture { + freshApp.launchEnvironment = ["UI_TEST_FIXTURE": fixture] + } + freshApp.launch() + app = freshApp + + // Navigate to Settings + let freshTabBar = TabBarScreen(app: app) + let freshSettings = freshTabBar.tapSettings() + freshSettings.assertVisible() + + // Upgrade banner should be visible (trial is active, not bypassed) + let upgradeBanner = freshSettings.upgradeBanner + let bannerVisible = upgradeBanner.waitForExistence(timeout: 5) + + captureScreenshot(name: "trial_banner_visible") + + XCTAssertTrue( + bannerVisible, + "Upgrade banner should be visible on fresh install (trial active, no bypass)" + ) + } + + /// TC-080: With --bypass-subscription, the trial banner is hidden. + func testTrialBanner_HiddenWithBypass() { + // Default BaseUITestCase has bypassSubscription = true + let tabBar = TabBarScreen(app: app) + let settingsScreen = tabBar.tapSettings() + settingsScreen.assertVisible() + + // Upgrade banner should NOT be visible + settingsScreen.assertUpgradeBannerHidden() + + captureScreenshot(name: "trial_banner_hidden_bypass") + } +} + +/// Separate test class for trial warning banner (TC-033) using expired trial state. +final class TrialWarningBannerTests: BaseUITestCase { + override var seedFixture: String? { "single_mood" } + override var bypassSubscription: Bool { false } + override var expireTrial: Bool { false } + + /// TC-033: When trial is active (not expired, not bypassed), Settings shows a warning banner. + func testTrialWarningBanner_Shown() { + let tabBar = TabBarScreen(app: app) + let settingsScreen = tabBar.tapSettings() + settingsScreen.assertVisible() + + // The upgrade banner should be visible + let upgradeBanner = settingsScreen.upgradeBanner + let visible = upgradeBanner.waitForExistence(timeout: 5) + + captureScreenshot(name: "trial_warning_banner") + + XCTAssertTrue( + visible, + "Trial warning banner should be visible when trial is active and subscription not bypassed" + ) + } +} diff --git a/docs/Feels_QA_Test_Plan.xlsx b/docs/Feels_QA_Test_Plan.xlsx index f5dd1e3..bd5898d 100644 Binary files a/docs/Feels_QA_Test_Plan.xlsx and b/docs/Feels_QA_Test_Plan.xlsx differ