// // MoodLoggerIntegrationTests.swift // Tests iOS // // Pipeline 10: Side Effects Orchestration // Pipeline 11: Full Pipeline Integration // import XCTest @testable import Feels @MainActor final class MoodLoggerIntegrationTests: XCTestCase { // MARK: - Helpers private func date(daysAgo: Int) -> Date { Calendar.current.date(byAdding: .day, value: -daysAgo, to: Calendar.current.startOfDay(for: Date()))! .addingTimeInterval(12 * 3600) } // MARK: - Setup / Teardown override func setUp() async throws { try await super.setUp() await MainActor.run { DataController.shared.clearDB() } } override func tearDown() async throws { await MainActor.run { DataController.shared.clearDB() } try await super.tearDown() } // MARK: - Pipeline 10: Side Effects Orchestration /// Verify that logMood creates an entry with the correct mood value. func testLogMood_CreatesEntry() { let targetDate = date(daysAgo: 0) MoodLogger.shared.logMood(.great, for: targetDate, entryType: .listView) let entry = DataController.shared.getEntry(byDate: targetDate) XCTAssertNotNil(entry, "Entry should exist after logMood") XCTAssertEqual(entry?.mood, .great, "Entry mood should be .great") XCTAssertEqual(entry?.entryType, EntryType.listView.rawValue, "Entry type should be listView") } /// Verify that logMood with .missing still creates the entry (DataController.add runs) /// but the side-effects path is skipped (applySideEffects guards against .missing). func testLogMood_SkipsSideEffectsForMissing() { let targetDate = date(daysAgo: 1) MoodLogger.shared.logMood(.missing, for: targetDate, entryType: .filledInMissing) let entry = DataController.shared.getEntry(byDate: targetDate) XCTAssertNotNil(entry, "Entry should be created even for .missing mood") XCTAssertEqual(entry?.mood, .missing, "Entry mood should be .missing") } /// Verify that logMood with syncHealthKit=false still creates the entry correctly /// and does not crash (we cannot verify HealthKit was NOT called without mocks). func testLogMood_WithDisabledHealthKit() { let targetDate = date(daysAgo: 2) MoodLogger.shared.logMood(.good, for: targetDate, entryType: .widget, syncHealthKit: false) let entry = DataController.shared.getEntry(byDate: targetDate) XCTAssertNotNil(entry, "Entry should be created with syncHealthKit=false") XCTAssertEqual(entry?.mood, .good, "Mood should be .good") XCTAssertEqual(entry?.entryType, EntryType.widget.rawValue, "Entry type should be widget") } /// Verify that updateMood returns true and updates the mood value when the entry exists. func testUpdateMood_ReturnsTrue() { let targetDate = date(daysAgo: 0) // Create initial entry MoodLogger.shared.logMood(.average, for: targetDate, entryType: .listView) let entryBefore = DataController.shared.getEntry(byDate: targetDate) XCTAssertEqual(entryBefore?.mood, .average, "Initial mood should be .average") // Update the mood let result = MoodLogger.shared.updateMood(entryDate: targetDate, withMood: .great) XCTAssertTrue(result, "updateMood should return true for an existing entry") let entryAfter = DataController.shared.getEntry(byDate: targetDate) XCTAssertEqual(entryAfter?.mood, .great, "Updated mood should be .great") } /// Verify that updateMood returns false for a date with no existing entry. func testUpdateMood_NonexistentDate_ReturnsFalse() { let emptyDate = date(daysAgo: 100) // far in the past, no entry let result = MoodLogger.shared.updateMood(entryDate: emptyDate, withMood: .good) XCTAssertFalse(result, "updateMood should return false when no entry exists") } /// Verify that deleteMood replaces the entry with a .missing mood and .filledInMissing type. func testDeleteMood_ReplacesWithMissing() { let targetDate = date(daysAgo: 1) // Create an entry MoodLogger.shared.logMood(.good, for: targetDate, entryType: .listView) let entryBefore = DataController.shared.getEntry(byDate: targetDate) XCTAssertEqual(entryBefore?.mood, .good, "Entry should be .good before delete") // Delete the mood MoodLogger.shared.deleteMood(forDate: targetDate) let entryAfter = DataController.shared.getEntry(byDate: targetDate) XCTAssertNotNil(entryAfter, "Entry should still exist after delete (replaced with .missing)") XCTAssertEqual(entryAfter?.mood, .missing, "Mood should be .missing after delete") XCTAssertEqual(entryAfter?.entryType, EntryType.filledInMissing.rawValue, "Entry type should be .filledInMissing after delete") } /// Verify that deleteAllData removes all entries from the database. func testDeleteAllData_ClearsEverything() { // Add 5 entries on consecutive days for i in 0..<5 { MoodLogger.shared.logMood(.good, for: date(daysAgo: i), entryType: .listView) } // Verify entries exist for i in 0..<5 { let entry = DataController.shared.getEntry(byDate: date(daysAgo: i)) XCTAssertNotNil(entry, "Entry should exist for day \(i) before deleteAllData") } // Delete all data MoodLogger.shared.deleteAllData() // Verify all entries are gone for i in 0..<5 { let entry = DataController.shared.getEntry(byDate: date(daysAgo: i)) XCTAssertNil(entry, "Entry should be nil for day \(i) after deleteAllData") } } // MARK: - Pipeline 11: Full Pipeline Integration /// Log consecutive moods and verify the streak increases with each day. func testPipeline_LogVote_StreakIncreases() { let day2 = date(daysAgo: 2) let day1 = date(daysAgo: 1) let day0 = date(daysAgo: 0) // Log day 2 (2 days ago) MoodLogger.shared.logMood(.good, for: day2, entryType: .listView) let streak1 = DataController.shared.calculateStreak(from: day2) XCTAssertEqual(streak1.streak, 1, "Streak from day 2 with only day 2 logged should be 1") // Log day 1 (1 day ago) MoodLogger.shared.logMood(.great, for: day1, entryType: .listView) let streak2 = DataController.shared.calculateStreak(from: day1) XCTAssertEqual(streak2.streak, 2, "Streak from day 1 with days 1-2 logged should be 2") // Log day 0 (today) MoodLogger.shared.logMood(.average, for: day0, entryType: .listView) let streak3 = DataController.shared.calculateStreak(from: day0) XCTAssertEqual(streak3.streak, 3, "Streak from day 0 with days 0-2 logged should be 3") } /// Log 3 consecutive moods, delete the middle one, verify streak breaks. func testPipeline_DeleteBreaksStreak() { let day0 = date(daysAgo: 0) let day1 = date(daysAgo: 1) let day2 = date(daysAgo: 2) // Log moods for 3 consecutive days MoodLogger.shared.logMood(.good, for: day0, entryType: .listView) MoodLogger.shared.logMood(.great, for: day1, entryType: .listView) MoodLogger.shared.logMood(.average, for: day2, entryType: .listView) let streakBefore = DataController.shared.calculateStreak(from: day0) XCTAssertEqual(streakBefore.streak, 3, "Streak from day 0 with 3 consecutive days should be 3") // Delete the middle day (day 1) MoodLogger.shared.deleteMood(forDate: day1) let streakAfter = DataController.shared.calculateStreak(from: day0) XCTAssertEqual(streakAfter.streak, 1, "Streak from day 0 after deleting day 1 should be 1 (only day 0 contiguous)") } /// Log moods with a gap, fill the gap, verify streak rebuilds. func testPipeline_LogPastDate_RebuildsStreak() { let day0 = date(daysAgo: 0) let day1 = date(daysAgo: 1) let day2 = date(daysAgo: 2) // Log days 0 and 2 (skip day 1) MoodLogger.shared.logMood(.good, for: day0, entryType: .listView) MoodLogger.shared.logMood(.average, for: day2, entryType: .listView) let streakGap = DataController.shared.calculateStreak(from: day0) XCTAssertEqual(streakGap.streak, 1, "Streak from day 0 with gap at day 1 should be 1") // Fill the gap by logging day 1 MoodLogger.shared.logMood(.great, for: day1, entryType: .listView) let streakFilled = DataController.shared.calculateStreak(from: day0) XCTAssertEqual(streakFilled.streak, 3, "Streak from day 0 after filling gap should be 3") } /// Log 3 consecutive moods, delete the oldest, verify current streak changes but recent entry is intact. func testPipeline_DeleteOlderDay_CurrentStreakUnchanged() { let day0 = date(daysAgo: 0) let day1 = date(daysAgo: 1) let day2 = date(daysAgo: 2) // Log moods for 3 consecutive days MoodLogger.shared.logMood(.great, for: day0, entryType: .listView) MoodLogger.shared.logMood(.good, for: day1, entryType: .listView) MoodLogger.shared.logMood(.average, for: day2, entryType: .listView) let streakBefore = DataController.shared.calculateStreak(from: day0) XCTAssertEqual(streakBefore.streak, 3, "Streak from day 0 with 3 consecutive days should be 3") // Delete the oldest day (day 2) MoodLogger.shared.deleteMood(forDate: day2) let streakAfter = DataController.shared.calculateStreak(from: day0) XCTAssertEqual(streakAfter.streak, 2, "Streak from day 0 after deleting day 2 should be 2 (days 0 and 1 still contiguous)") // Verify day 0 entry is still intact let entry = DataController.shared.getEntry(byDate: day0) XCTAssertNotNil(entry, "Day 0 entry should still exist") XCTAssertEqual(entry?.mood, .great, "Day 0 mood should still be .great") } /// Full voting date and missing vote lifecycle: /// OnboardingData with .Today and time 00:00 means voting is always "passed" /// so getCurrentVotingDate returns the `now` date itself. func testPipeline_VotingDateAndMissingVote() { let calendar = Calendar.current // 1. Create OnboardingData with .Today and time at 00:00 (always passed) let onboarding = OnboardingData() onboarding.inputDay = .Today onboarding.date = calendar.startOfDay(for: Date()) // hour 0, minute 0 // 2. Set `now` to today at noon let todayNoon = calendar.startOfDay(for: Date()).addingTimeInterval(12 * 3600) // 3. Get voting date -- should be `now` (today) since time is passed and inputDay is .Today let votingDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: onboarding, now: todayNoon) XCTAssertTrue( calendar.isDate(votingDate, inSameDayAs: todayNoon), "Voting date should be the same day as `now` when inputDay=.Today and time has passed" ) // 4. isMissingCurrentVote should be true (no entry yet) let missingBefore = ShowBasedOnVoteLogics.isMissingCurrentVote(onboardingData: onboarding, now: todayNoon) XCTAssertTrue(missingBefore, "Should be missing current vote before logging") // 5. Log mood for the voting date MoodLogger.shared.logMood(.good, for: votingDate, entryType: .listView) // 6. isMissingCurrentVote with same `now` should be false let missingAfter = ShowBasedOnVoteLogics.isMissingCurrentVote(onboardingData: onboarding, now: todayNoon) XCTAssertFalse(missingAfter, "Should NOT be missing current vote after logging") // 7. Move `now` forward by 1 day let tomorrowNoon = todayNoon.addingTimeInterval(24 * 3600) // 8. isMissingCurrentVote should be true again (new day, no entry) let missingNextDay = ShowBasedOnVoteLogics.isMissingCurrentVote(onboardingData: onboarding, now: tomorrowNoon) XCTAssertTrue(missingNextDay, "Should be missing vote for the next day") // 9. Log mood for the new voting date let newVotingDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: onboarding, now: tomorrowNoon) MoodLogger.shared.logMood(.great, for: newVotingDate, entryType: .listView) // 10. Calculate streak from the new voting date -- should be 2 (two consecutive days) let streak = DataController.shared.calculateStreak(from: newVotingDate) XCTAssertEqual(streak.streak, 2, "Streak should be 2 after logging moods on two consecutive days") XCTAssertEqual(streak.todaysMood, .great, "Today's mood should be .great") } }