Add 57 tests covering all data mutation paths

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>
This commit is contained in:
Trey t
2026-02-14 23:25:14 -06:00
parent f1cd81c395
commit 4125c93dfe
6 changed files with 978 additions and 24 deletions

View File

@@ -0,0 +1,282 @@
//
// 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")
}
}