Refactor ShowBasedOnVoteLogics to accept injectable `now: Date` parameter across all methods, enabling deterministic testing of voting date logic without simulator clock manipulation. Test coverage (55 new tests across 3 files): - Pipeline 1: Streak calculation (7 tests) — consecutive, gaps, missing/placeholder exclusion - Pipeline 2: Duplicate prevention (5 tests) — add replaces, removeDuplicates keeps best - Pipeline 3: Fill missing dates (4 tests) — gap fill, idempotent, no overwrite - Pipeline 4: Delete flows (5 tests) — clearDB, deleteLast, deleteAllEntries - Pipeline 5: Update flows (5 tests) — mood, notes, photo set/clear - Pipeline 6: Batch import (4 tests) — bulk insert, replace, dedup in batch - Pipeline 7: Data listeners (3 tests) — fire on save, multiple listeners, refreshFromDisk - Pipeline 8: Boundary edge cases (5 tests) — midnight, 23:59, day boundary leak - Pipeline 9: Voting date logic (5 tests) — Today/Previous x before/after voting time - Pipeline 10: Side effects orchestration (7 tests) — logMood, updateMood, deleteMood - Pipeline 11: Full integration (5 tests) — streak grows/breaks/rebuilds, voting lifecycle Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
283 lines
12 KiB
Swift
283 lines
12 KiB
Swift
//
|
|
// 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")
|
|
}
|
|
}
|