From 4125c93dfe383c3a9211c57b14669c95ae33be29 Mon Sep 17 00:00:00 2001 From: Trey t Date: Sat, 14 Feb 2026 23:25:14 -0600 Subject: [PATCH] Add 57 tests covering all data mutation paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Feels.xcodeproj/project.pbxproj | 22 +- Shared/ShowBasedOnVoteLogics.swift | 40 +- Tests iOS/DataControllerTests.swift | 423 +++++++++++++++++++++ Tests iOS/DataListenerAndVotingTests.swift | 233 ++++++++++++ Tests iOS/MoodLoggerIntegrationTests.swift | 282 ++++++++++++++ Tests iOS/Tests_iOS.swift | 2 +- 6 files changed, 978 insertions(+), 24 deletions(-) create mode 100644 Tests iOS/DataControllerTests.swift create mode 100644 Tests iOS/DataListenerAndVotingTests.swift create mode 100644 Tests iOS/MoodLoggerIntegrationTests.swift diff --git a/Feels.xcodeproj/project.pbxproj b/Feels.xcodeproj/project.pbxproj index ec406dc..81ea1a6 100644 --- a/Feels.xcodeproj/project.pbxproj +++ b/Feels.xcodeproj/project.pbxproj @@ -13,6 +13,9 @@ 1CB4D0A028787D8A00902A56 /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1CB4D09F28787D8A00902A56 /* StoreKit.framework */; }; 1CD90B07278C7DE0001C4FEA /* Tests_iOS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CD90B06278C7DE0001C4FEA /* Tests_iOS.swift */; }; 1CD90B09278C7DE0001C4FEA /* Tests_iOSLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CD90B08278C7DE0001C4FEA /* Tests_iOSLaunchTests.swift */; }; + 1CA1B00130000001001C4FEA /* DataListenerAndVotingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CA1B00130000000001C4FEA /* DataListenerAndVotingTests.swift */; }; + 1CA1B00130000003001C4FEA /* DataControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CA1B00130000002001C4FEA /* DataControllerTests.swift */; }; + 1CA1B00130000005001C4FEA /* MoodLoggerIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CA1B00130000004001C4FEA /* MoodLoggerIntegrationTests.swift */; }; 1CD90B13278C7DE0001C4FEA /* Tests_macOS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CD90B12278C7DE0001C4FEA /* Tests_macOS.swift */; }; 1CD90B15278C7DE0001C4FEA /* Tests_macOSLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CD90B14278C7DE0001C4FEA /* Tests_macOSLaunchTests.swift */; }; 1CD90B48278C7E7A001C4FEA /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1CD90B47278C7E7A001C4FEA /* WidgetKit.framework */; platformFilter = maccatalyst; }; @@ -72,6 +75,9 @@ 1CD90AFD278C7DE0001C4FEA /* macOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = macOS.entitlements; sourceTree = ""; }; 1CD90B02278C7DE0001C4FEA /* Tests iOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Tests iOS.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; 1CD90B06278C7DE0001C4FEA /* Tests_iOS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests_iOS.swift; sourceTree = ""; }; + 1CA1B00130000000001C4FEA /* DataListenerAndVotingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataListenerAndVotingTests.swift; sourceTree = ""; }; + 1CA1B00130000002001C4FEA /* DataControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataControllerTests.swift; sourceTree = ""; }; + 1CA1B00130000004001C4FEA /* MoodLoggerIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoodLoggerIntegrationTests.swift; sourceTree = ""; }; 1CD90B08278C7DE0001C4FEA /* Tests_iOSLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests_iOSLaunchTests.swift; sourceTree = ""; }; 1CD90B0E278C7DE0001C4FEA /* Tests macOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Tests macOS.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; 1CD90B12278C7DE0001C4FEA /* Tests_macOS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests_macOS.swift; sourceTree = ""; }; @@ -245,6 +251,9 @@ children = ( 1CD90B06278C7DE0001C4FEA /* Tests_iOS.swift */, 1CD90B08278C7DE0001C4FEA /* Tests_iOSLaunchTests.swift */, + 1CA1B00130000000001C4FEA /* DataListenerAndVotingTests.swift */, + 1CA1B00130000002001C4FEA /* DataControllerTests.swift */, + 1CA1B00130000004001C4FEA /* MoodLoggerIntegrationTests.swift */, ); path = "Tests iOS"; sourceTree = ""; @@ -331,7 +340,7 @@ name = "Tests iOS"; productName = "Tests iOS"; productReference = 1CD90B02278C7DE0001C4FEA /* Tests iOS.xctest */; - productType = "com.apple.product-type.bundle.ui-testing"; + productType = "com.apple.product-type.bundle.unit-test"; }; 1CD90B0D278C7DE0001C4FEA /* Tests macOS */ = { isa = PBXNativeTarget; @@ -537,6 +546,9 @@ files = ( 1CD90B09278C7DE0001C4FEA /* Tests_iOSLaunchTests.swift in Sources */, 1CD90B07278C7DE0001C4FEA /* Tests_iOS.swift in Sources */, + 1CA1B00130000001001C4FEA /* DataListenerAndVotingTests.swift in Sources */, + 1CA1B00130000003001C4FEA /* DataControllerTests.swift in Sources */, + 1CA1B00130000005001C4FEA /* MoodLoggerIntegrationTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -881,10 +893,11 @@ 1CD90B29278C7DE0001C4FEA /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.2; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = "com.88oak.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -892,6 +905,7 @@ SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Feels.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Feels"; TEST_TARGET_NAME = "Feels (iOS)"; }; name = Debug; @@ -899,10 +913,11 @@ 1CD90B2A278C7DE0001C4FEA /* Release */ = { isa = XCBuildConfiguration; buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.2; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = "com.88oak.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -910,6 +925,7 @@ SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Feels.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Feels"; TEST_TARGET_NAME = "Feels (iOS)"; VALIDATE_PRODUCT = YES; }; diff --git a/Shared/ShowBasedOnVoteLogics.swift b/Shared/ShowBasedOnVoteLogics.swift index 5fbbced..085ab69 100644 --- a/Shared/ShowBasedOnVoteLogics.swift +++ b/Shared/ShowBasedOnVoteLogics.swift @@ -31,15 +31,15 @@ import SwiftData */ @MainActor class ShowBasedOnVoteLogics { - static private func returnCurrentVoteStatus(onboardingData: OnboardingData) -> (passedTimeToVote: Bool, inputDay: DayOptions) { - let passedTimeToVote = ShowBasedOnVoteLogics.passedTodaysVotingUnlock(voteDate: onboardingData.date) + static private func returnCurrentVoteStatus(onboardingData: OnboardingData, now: Date = Date()) -> (passedTimeToVote: Bool, inputDay: DayOptions) { + let passedTimeToVote = ShowBasedOnVoteLogics.passedTodaysVotingUnlock(voteDate: onboardingData.date, now: now) let inputDay: DayOptions = onboardingData.inputDay return (passedTimeToVote, inputDay) } - static public func passedTodaysVotingUnlock(voteDate: Date) -> Bool { - let currentDateComp = Calendar.current.dateComponents([.hour, .minute], from: Date()) + static public func passedTodaysVotingUnlock(voteDate: Date, now: Date = Date()) -> Bool { + let currentDateComp = Calendar.current.dateComponents([.hour, .minute], from: now) let savedDateComp = Calendar.current.dateComponents([.hour, .minute], from: voteDate) if let currentHour = currentDateComp.hour, @@ -59,8 +59,8 @@ class ShowBasedOnVoteLogics { return false } - static public func isMissingCurrentVote(onboardingData: OnboardingData) -> Bool { - let startDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: onboardingData).startOfDay + static public func isMissingCurrentVote(onboardingData: OnboardingData, now: Date = Date()) -> Bool { + let startDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: onboardingData, now: now).startOfDay #if WIDGET_EXTENSION let entry = WidgetDataProvider.shared.getEntry(byDate: startDate) #else @@ -68,45 +68,45 @@ class ShowBasedOnVoteLogics { #endif return entry == nil || entry?.mood == .missing } - - static public func getCurrentVotingDate(onboardingData: OnboardingData) -> Date { + + static public func getCurrentVotingDate(onboardingData: OnboardingData, now: Date = Date()) -> Date { var date: Date? - let currentVoteStatus = ShowBasedOnVoteLogics.returnCurrentVoteStatus(onboardingData: onboardingData) + let currentVoteStatus = ShowBasedOnVoteLogics.returnCurrentVoteStatus(onboardingData: onboardingData, now: now) // note to future self, this should account for midnight until next voting unlock // the vote at 12:03 am will not be passed time, and will be for yesterday switch (currentVoteStatus.passedTimeToVote, currentVoteStatus.inputDay) { case (false, .Today): // if we're passed time to vote and the voting type is previous - last vote should be -1 - date = Calendar.current.date(byAdding: .day, value: -1, to: Date()) + date = Calendar.current.date(byAdding: .day, value: -1, to: now) case (true, .Today): // if we're passed time to vote and the voting type is previous - last vote should be today - date = Date() - + date = now + case (false, .Previous): // if we're passed time to vote and the voting type is previous - last vote should be -2 - date = Calendar.current.date(byAdding: .day, value: -2, to: Date()) + date = Calendar.current.date(byAdding: .day, value: -2, to: now) case (true, .Previous): // if we're passed time to vote and the voting type is previous - last vote should be -1 - date = Calendar.current.date(byAdding: .day, value: -1, to: Date()) + date = Calendar.current.date(byAdding: .day, value: -1, to: now) } guard let date = date else { fatalError("missing getCurrentVotingDate") } - + return date } - - static public func getVotingTitle(onboardingData: OnboardingData) -> String { - let currentVoteStatus = ShowBasedOnVoteLogics.returnCurrentVoteStatus(onboardingData: onboardingData) + + static public func getVotingTitle(onboardingData: OnboardingData, now: Date = Date()) -> String { + let currentVoteStatus = ShowBasedOnVoteLogics.returnCurrentVoteStatus(onboardingData: onboardingData, now: now) switch (currentVoteStatus.passedTimeToVote, currentVoteStatus.inputDay) { case (false, .Today): return String(localized: "add_mood_header_view_title_yesterday") case (true, .Today): return String(localized: "add_mood_header_view_title_today") - + case (false, .Previous): - let date = Calendar.current.date(byAdding: .day, value: -2, to: Date())! + let date = Calendar.current.date(byAdding: .day, value: -2, to: now)! return String(format: String(localized: "add_mood_header_view_title"), Random.weekdayName(fromDate: date)) case (true, .Previous): return String(localized: "add_mood_header_view_title_yesterday") diff --git a/Tests iOS/DataControllerTests.swift b/Tests iOS/DataControllerTests.swift new file mode 100644 index 0000000..127211d --- /dev/null +++ b/Tests iOS/DataControllerTests.swift @@ -0,0 +1,423 @@ +// +// DataControllerTests.swift +// Tests iOS +// +// Tests for DataController CRUD operations, streak calculation, +// duplicate prevention, fill missing dates, delete flows, update flows, +// and batch import. +// + +import XCTest +@testable import Feels + +@MainActor +class DataControllerTests: XCTestCase { + + private var dc: DataController { DataController.shared } + + override func setUp() { + super.setUp() + dc.clearDB() + } + + override func tearDown() { + dc.clearDB() + super.tearDown() + } + + // MARK: - Helpers + + /// Returns a date N days ago at noon (avoids timezone boundary issues). + private func date(daysAgo: Int) -> Date { + let startOfToday = Calendar.current.startOfDay(for: Date()) + return Calendar.current.date(byAdding: .day, value: -daysAgo, to: startOfToday)! + .addingTimeInterval(12 * 3600) + } + + /// Fetches all entries currently stored in the DataController. + private func allEntries() -> [MoodEntryModel] { + dc.getData( + startDate: Date(timeIntervalSince1970: 0), + endDate: Date().addingTimeInterval(86400), + includedDays: [] + ) + } + + /// Directly inserts an entry into the model context, bypassing + /// DataController.add() so we can create duplicates for testing. + private func insertDirectly( + mood: Mood, + date: Date, + entryType: EntryType = .listView, + timestamp: Date? = nil + ) { + let entry = MoodEntryModel(forDate: date, mood: mood, entryType: entryType) + if let timestamp { + entry.timestamp = timestamp + } + dc.container.mainContext.insert(entry) + try? dc.container.mainContext.save() + } + + // MARK: - Pipeline 1: Streak Calculation + + func testStreak_FiveConsecutiveDays() { + // Add moods for today through 4 days ago (5 consecutive days) + for i in 0...4 { + dc.add(mood: .good, forDate: date(daysAgo: i), entryType: .listView) + } + + let (streak, _) = dc.calculateStreak(from: date(daysAgo: 0)) + XCTAssertEqual(streak, 5, "Five consecutive days should yield streak of 5") + } + + func testStreak_GapBreaksStreak() { + // Days 0, 1, 2, and 4 (skip day 3) + dc.add(mood: .good, forDate: date(daysAgo: 0), entryType: .listView) + dc.add(mood: .good, forDate: date(daysAgo: 1), entryType: .listView) + dc.add(mood: .good, forDate: date(daysAgo: 2), entryType: .listView) + dc.add(mood: .good, forDate: date(daysAgo: 4), entryType: .listView) + + let (streak, _) = dc.calculateStreak(from: date(daysAgo: 0)) + XCTAssertEqual(streak, 3, "Gap at day 3 should break streak at 3") + } + + func testStreak_OnlyToday() { + dc.add(mood: .great, forDate: date(daysAgo: 0), entryType: .listView) + + let (streak, _) = dc.calculateStreak(from: date(daysAgo: 0)) + XCTAssertEqual(streak, 1, "Single entry today should be streak of 1") + } + + func testStreak_NoEntries() { + let (streak, todaysMood) = dc.calculateStreak(from: date(daysAgo: 0)) + XCTAssertEqual(streak, 0, "Empty DB should give streak of 0") + XCTAssertNil(todaysMood, "No entry means todaysMood should be nil") + } + + func testStreak_MissingDoesNotCount() { + // Days 0 and 1 are .good, day 2 is .missing + dc.add(mood: .good, forDate: date(daysAgo: 0), entryType: .listView) + dc.add(mood: .good, forDate: date(daysAgo: 1), entryType: .listView) + dc.add(mood: .missing, forDate: date(daysAgo: 2), entryType: .filledInMissing) + + let (streak, _) = dc.calculateStreak(from: date(daysAgo: 0)) + XCTAssertEqual(streak, 2, ".missing entries should not extend the streak") + } + + func testStreak_PlaceholderDoesNotCount() { + dc.add(mood: .good, forDate: date(daysAgo: 0), entryType: .listView) + // Directly insert a .placeholder since add() would not normally be used for this + insertDirectly(mood: .placeholder, date: date(daysAgo: 1)) + + let (streak, _) = dc.calculateStreak(from: date(daysAgo: 0)) + XCTAssertEqual(streak, 1, ".placeholder entries should not extend the streak") + } + + func testStreak_StartsFromPreviousDayIfTodayMissing() { + // No entry for today (day 0), but days 1, 2, 3 have entries + dc.add(mood: .good, forDate: date(daysAgo: 1), entryType: .listView) + dc.add(mood: .good, forDate: date(daysAgo: 2), entryType: .listView) + dc.add(mood: .good, forDate: date(daysAgo: 3), entryType: .listView) + + let (streak, todaysMood) = dc.calculateStreak(from: date(daysAgo: 0)) + XCTAssertEqual(streak, 3, "Should count backwards from day 1 when today is empty") + XCTAssertNil(todaysMood, "Today has no entry so todaysMood should be nil") + } + + // MARK: - Pipeline 2: Duplicate Prevention + + func testAdd_ReplacesExistingEntry() { + let targetDate = date(daysAgo: 1) + dc.add(mood: .good, forDate: targetDate, entryType: .listView) + dc.add(mood: .great, forDate: targetDate, entryType: .listView) + + let entries = dc.getAllEntries(byDate: targetDate) + XCTAssertEqual(entries.count, 1, "add() should replace existing entry, leaving exactly 1") + XCTAssertEqual(entries.first?.mood, .great, "The surviving entry should have the new mood") + } + + func testRemoveDuplicates_KeepsNonMissing() { + let targetDate = date(daysAgo: 2) + // Insert two entries for same date bypassing add() so both survive + insertDirectly(mood: .missing, date: targetDate, entryType: .filledInMissing) + insertDirectly(mood: .good, date: targetDate, entryType: .listView) + + let beforeCount = dc.getAllEntries(byDate: targetDate).count + XCTAssertEqual(beforeCount, 2, "Should have 2 entries before dedup") + + let removed = dc.removeDuplicates() + XCTAssertEqual(removed, 1, "Should remove 1 duplicate") + + let remaining = dc.getAllEntries(byDate: targetDate) + XCTAssertEqual(remaining.count, 1, "Should have 1 entry after dedup") + XCTAssertEqual(remaining.first?.mood, .good, "Non-missing entry should survive") + } + + func testRemoveDuplicates_KeepsLatestTimestamp() { + let targetDate = date(daysAgo: 3) + let earlierTimestamp = Date().addingTimeInterval(-3600) + let laterTimestamp = Date() + + insertDirectly(mood: .good, date: targetDate, timestamp: earlierTimestamp) + insertDirectly(mood: .bad, date: targetDate, timestamp: laterTimestamp) + + let removed = dc.removeDuplicates() + XCTAssertEqual(removed, 1, "Should remove 1 duplicate") + + let remaining = dc.getAllEntries(byDate: targetDate) + XCTAssertEqual(remaining.count, 1, "Should have 1 entry after dedup") + XCTAssertEqual(remaining.first?.mood, .bad, "Entry with later timestamp should survive") + } + + func testRemoveDuplicates_NoDuplicates_ReturnsZero() { + dc.add(mood: .good, forDate: date(daysAgo: 1), entryType: .listView) + dc.add(mood: .great, forDate: date(daysAgo: 2), entryType: .listView) + dc.add(mood: .bad, forDate: date(daysAgo: 3), entryType: .listView) + + let removed = dc.removeDuplicates() + XCTAssertEqual(removed, 0, "No duplicates should result in 0 removals") + } + + func testRemoveDuplicates_MultipleDates() { + let dateA = date(daysAgo: 4) + let dateB = date(daysAgo: 5) + + // Create duplicates on both dates + insertDirectly(mood: .good, date: dateA) + insertDirectly(mood: .great, date: dateA) + insertDirectly(mood: .bad, date: dateB) + insertDirectly(mood: .average, date: dateB) + + let removed = dc.removeDuplicates() + XCTAssertEqual(removed, 2, "Should remove 1 duplicate from each date = 2 total") + + XCTAssertEqual(dc.getAllEntries(byDate: dateA).count, 1, "Date A should have 1 entry") + XCTAssertEqual(dc.getAllEntries(byDate: dateB).count, 1, "Date B should have 1 entry") + } + + // MARK: - Pipeline 3: Fill Missing Dates + + func testFillMissing_CreatesGapEntries() { + // Add entries 10 and 6 days ago, leaving a gap of 7, 8, 9 + dc.add(mood: .good, forDate: date(daysAgo: 10), entryType: .listView) + dc.add(mood: .great, forDate: date(daysAgo: 6), entryType: .listView) + + dc.fillInMissingDates() + + // Check that the gap dates (7, 8, 9) were filled + for day in 7...9 { + let entry = dc.getEntry(byDate: date(daysAgo: day)) + XCTAssertNotNil(entry, "Day \(day) ago should have been filled in") + if let entry { + XCTAssertEqual(entry.mood, .missing, "Filled entry should be .missing") + XCTAssertEqual(entry.entryType, EntryType.filledInMissing.rawValue, + "Filled entry should have .filledInMissing entryType") + } + } + } + + func testFillMissing_EmptyDB_NoOp() { + dc.fillInMissingDates() + let entries = allEntries() + XCTAssertEqual(entries.count, 0, "Empty DB fill should produce no entries") + } + + func testFillMissing_Idempotent() { + dc.add(mood: .good, forDate: date(daysAgo: 10), entryType: .listView) + dc.add(mood: .great, forDate: date(daysAgo: 8), entryType: .listView) + + dc.fillInMissingDates() + let countAfterFirst = allEntries().count + + dc.fillInMissingDates() + let countAfterSecond = allEntries().count + + XCTAssertEqual(countAfterFirst, countAfterSecond, + "Running fillInMissingDates twice should not create extra entries") + } + + func testFillMissing_DoesNotOverwriteReal() { + // Add a real .good entry on day 9, plus bookend entries + dc.add(mood: .good, forDate: date(daysAgo: 10), entryType: .listView) + dc.add(mood: .good, forDate: date(daysAgo: 9), entryType: .listView) + dc.add(mood: .great, forDate: date(daysAgo: 7), entryType: .listView) + + dc.fillInMissingDates() + + let entry = dc.getEntry(byDate: date(daysAgo: 9)) + XCTAssertNotNil(entry) + XCTAssertEqual(entry?.mood, .good, "Real entry should not be overwritten by fill") + } + + // MARK: - Pipeline 4: Delete Flows + + func testClearDB_RemovesAllEntries() { + for i in 0..<5 { + dc.add(mood: .good, forDate: date(daysAgo: i), entryType: .listView) + } + XCTAssertEqual(allEntries().count, 5, "Should have 5 entries before clear") + + dc.clearDB() + XCTAssertEqual(allEntries().count, 0, "clearDB should remove all entries") + } + + func testClearDB_EmptyDB_NoError() { + // Should not crash or throw on empty database + dc.clearDB() + XCTAssertEqual(allEntries().count, 0) + } + + func testDeleteAllEntries_ForDate() { + let dateToDelete = date(daysAgo: 3) + let dateToKeep = date(daysAgo: 5) + + dc.add(mood: .good, forDate: dateToDelete, entryType: .listView) + dc.add(mood: .great, forDate: dateToKeep, entryType: .listView) + + dc.deleteAllEntries(forDate: dateToDelete) + + XCTAssertNil(dc.getEntry(byDate: dateToDelete), "Deleted date should have no entry") + XCTAssertNotNil(dc.getEntry(byDate: dateToKeep), "Other date should still have its entry") + } + + func testDeleteLast_RemovesMostRecentDays() { + // deleteLast(numberOfEntries:) deletes entries from the last N days + // relative to Date(). So entries within last 3 days should be removed. + // Add entries on days 0, 1, 2, 3, 4 + for i in 0..<5 { + dc.add(mood: .good, forDate: date(daysAgo: i), entryType: .listView) + } + XCTAssertEqual(allEntries().count, 5, "Should start with 5 entries") + + // Delete last 3 days (days 0, 1, 2) + dc.deleteLast(numberOfEntries: 3) + + // Days 3 and 4 should remain + XCTAssertNil(dc.getEntry(byDate: date(daysAgo: 0)), "Day 0 should be deleted") + XCTAssertNil(dc.getEntry(byDate: date(daysAgo: 1)), "Day 1 should be deleted") + XCTAssertNil(dc.getEntry(byDate: date(daysAgo: 2)), "Day 2 should be deleted") + XCTAssertNotNil(dc.getEntry(byDate: date(daysAgo: 3)), "Day 3 should survive") + XCTAssertNotNil(dc.getEntry(byDate: date(daysAgo: 4)), "Day 4 should survive") + } + + func testAdd_DeletesExistingBeforeInsert() { + let targetDate = date(daysAgo: 2) + + dc.add(mood: .good, forDate: targetDate, entryType: .listView) + let firstEntries = dc.getAllEntries(byDate: targetDate) + XCTAssertEqual(firstEntries.count, 1, "Should have 1 entry after first add") + + dc.add(mood: .great, forDate: targetDate, entryType: .widget) + let secondEntries = dc.getAllEntries(byDate: targetDate) + XCTAssertEqual(secondEntries.count, 1, "Should still have 1 entry after second add") + XCTAssertEqual(secondEntries.first?.mood, .great, "Entry should reflect the latest mood") + XCTAssertEqual(secondEntries.first?.entryType, EntryType.widget.rawValue, + "Entry should reflect the latest entryType") + } + + // MARK: - Pipeline 5: Update Flows + + func testUpdateMood_ChangesMoodValue() { + let targetDate = date(daysAgo: 1) + dc.add(mood: .good, forDate: targetDate, entryType: .listView) + + let result = dc.update(entryDate: targetDate, withMood: .great) + XCTAssertTrue(result, "Update should return true for existing entry") + + let entry = dc.getEntry(byDate: targetDate) + XCTAssertEqual(entry?.mood, .great, "Mood should be updated to .great") + } + + func testUpdateMood_NonexistentDate_ReturnsFalse() { + let result = dc.update(entryDate: date(daysAgo: 99), withMood: .great) + XCTAssertFalse(result, "Update on non-existent date should return false") + } + + func testUpdateNotes_SavesText() { + let targetDate = date(daysAgo: 1) + dc.add(mood: .good, forDate: targetDate, entryType: .listView) + + let result = dc.updateNotes(forDate: targetDate, notes: "Had a great day") + XCTAssertTrue(result, "updateNotes should return true for existing entry") + + let entry = dc.getEntry(byDate: targetDate) + XCTAssertEqual(entry?.notes, "Had a great day", "Notes should be saved") + } + + func testUpdateNotes_NilClearsNotes() { + let targetDate = date(daysAgo: 1) + dc.add(mood: .good, forDate: targetDate, entryType: .listView) + dc.updateNotes(forDate: targetDate, notes: "Some text") + + let result = dc.updateNotes(forDate: targetDate, notes: nil) + XCTAssertTrue(result, "updateNotes with nil should return true") + + let entry = dc.getEntry(byDate: targetDate) + XCTAssertNil(entry?.notes, "Notes should be cleared to nil") + } + + func testUpdatePhoto_SetsAndClears() { + let targetDate = date(daysAgo: 1) + dc.add(mood: .good, forDate: targetDate, entryType: .listView) + + let photoUUID = UUID() + let setResult = dc.updatePhoto(forDate: targetDate, photoID: photoUUID) + XCTAssertTrue(setResult, "updatePhoto should return true for existing entry") + + var entry = dc.getEntry(byDate: targetDate) + XCTAssertEqual(entry?.photoID, photoUUID, "Photo ID should be set") + + let clearResult = dc.updatePhoto(forDate: targetDate, photoID: nil) + XCTAssertTrue(clearResult, "Clearing photo should return true") + + entry = dc.getEntry(byDate: targetDate) + XCTAssertNil(entry?.photoID, "Photo ID should be cleared to nil") + } + + // MARK: - Pipeline 6: Batch Import + + func testBatchImport_InsertsAll() { + var batchEntries = [(mood: Mood, date: Date, entryType: EntryType)]() + for i in 1...10 { + batchEntries.append((mood: .good, date: date(daysAgo: i), entryType: .listView)) + } + + dc.addBatch(entries: batchEntries) + + let entries = allEntries() + XCTAssertEqual(entries.count, 10, "Batch import of 10 entries should result in 10 stored") + } + + func testBatchImport_ReplacesExisting() { + let targetDate = date(daysAgo: 3) + dc.add(mood: .good, forDate: targetDate, entryType: .listView) + + dc.addBatch(entries: [ + (mood: .great, date: targetDate, entryType: .shortcut) + ]) + + let entries = dc.getAllEntries(byDate: targetDate) + XCTAssertEqual(entries.count, 1, "Batch should replace existing entry") + XCTAssertEqual(entries.first?.mood, .great, "Replacement should have the batch mood") + } + + func testBatchImport_DuplicatesInBatch_LastWins() { + let targetDate = date(daysAgo: 5) + + dc.addBatch(entries: [ + (mood: .bad, date: targetDate, entryType: .listView), + (mood: .great, date: targetDate, entryType: .listView) + ]) + + let entries = dc.getAllEntries(byDate: targetDate) + XCTAssertEqual(entries.count, 1, "Duplicate dates in batch should resolve to 1 entry") + XCTAssertEqual(entries.first?.mood, .great, "Last entry in batch for same date should win") + } + + func testBatchImport_EmptyArray_NoOp() { + dc.addBatch(entries: []) + let entries = allEntries() + XCTAssertEqual(entries.count, 0, "Empty batch import should leave DB empty") + } +} diff --git a/Tests iOS/DataListenerAndVotingTests.swift b/Tests iOS/DataListenerAndVotingTests.swift new file mode 100644 index 0000000..f1a2136 --- /dev/null +++ b/Tests iOS/DataListenerAndVotingTests.swift @@ -0,0 +1,233 @@ +// +// DataListenerAndVotingTests.swift +// Tests iOS +// +// Tests for data listener mechanics, cross-boundary edge cases, and voting date logic. +// + +import XCTest +@testable import Feels + +@MainActor +class DataListenerAndVotingTests: XCTestCase { + + override func setUp() { + super.setUp() + DataController.shared.clearDB() + } + + override func tearDown() { + DataController.shared.clearDB() + super.tearDown() + } + + // MARK: - Helpers + + private func date(daysAgo: Int) -> Date { + Calendar.current.date( + byAdding: .day, + value: -daysAgo, + to: Calendar.current.startOfDay(for: Date()) + )!.addingTimeInterval(12 * 3600) + } + + private func makeDate(year: Int, month: Int, day: Int, hour: Int, minute: Int) -> Date { + var components = DateComponents() + components.year = year + components.month = month + components.day = day + components.hour = hour + components.minute = minute + return Calendar.current.date(from: components)! + } + + // MARK: - Pipeline 7: Data Listener Mechanics + + func testListener_FiresOnSave() { + var fired = false + DataController.shared.addNewDataListener { + fired = true + } + + DataController.shared.add(mood: .good, forDate: date(daysAgo: 0), entryType: .listView) + + XCTAssertTrue(fired, "Listener should fire when add() calls saveAndRunDataListeners internally") + } + + func testListener_MultipleListenersFire() { + var counterA = 0 + var counterB = 0 + + DataController.shared.addNewDataListener { + counterA += 1 + } + DataController.shared.addNewDataListener { + counterB += 1 + } + + DataController.shared.add(mood: .great, forDate: date(daysAgo: 1), entryType: .listView) + + XCTAssertEqual(counterA, 1, "First listener should have fired exactly once") + XCTAssertEqual(counterB, 1, "Second listener should have fired exactly once") + } + + func testListener_RefreshFromDisk_FiresListeners() { + var fired = false + DataController.shared.addNewDataListener { + fired = true + } + + DataController.shared.refreshFromDisk() + + XCTAssertTrue(fired, "Listener should fire when refreshFromDisk() is called") + } + + // MARK: - Pipeline 8: Cross-Boundary Edge Cases + + func testEntry_AtMidnight_FoundByGetEntry() { + let targetDate = date(daysAgo: 3) + let midnight = Calendar.current.startOfDay(for: targetDate) + + DataController.shared.add(mood: .good, forDate: midnight, entryType: .listView) + + let fetched = DataController.shared.getEntry(byDate: targetDate) + XCTAssertNotNil(fetched, "Entry inserted at midnight should be found by getEntry for that date") + XCTAssertEqual(fetched?.mood, .good) + } + + func testEntry_At2359_FoundByGetEntry() { + let targetDate = date(daysAgo: 2) + let startOfDay = Calendar.current.startOfDay(for: targetDate) + let at2359 = startOfDay.addingTimeInterval(23 * 3600 + 59 * 60) // 23:59 + + DataController.shared.add(mood: .bad, forDate: at2359, entryType: .listView) + + let fetched = DataController.shared.getEntry(byDate: targetDate) + XCTAssertNotNil(fetched, "Entry inserted at 23:59 should be found by getEntry for that date") + XCTAssertEqual(fetched?.mood, .bad) + } + + func testEntry_DoesNotLeakToNextDay() { + let day1 = date(daysAgo: 5) + let startOfDay1 = Calendar.current.startOfDay(for: day1) + let at2359 = startOfDay1.addingTimeInterval(23 * 3600 + 59 * 60) // 23:59 of day 1 + + DataController.shared.add(mood: .average, forDate: at2359, entryType: .listView) + + // Query for day 2 (the next day) + let day2 = Calendar.current.date(byAdding: .day, value: 1, to: day1)! + let fetched = DataController.shared.getEntry(byDate: day2) + XCTAssertNil(fetched, "Entry at 23:59 of day 1 should NOT appear when querying day 2") + } + + func testGetEntry_BoundaryLeak() { + // getEntry uses `entry.forDate <= endDate` where endDate = startOfDay + 1 day. + // An entry at exactly startOfDay of day 2 equals endDate for a day 1 query. + // This documents the boundary behavior: the <= predicate means + // an entry at exactly midnight of the NEXT day WILL match the previous day's query. + let day1 = date(daysAgo: 4) + let day2 = Calendar.current.date(byAdding: .day, value: 1, to: day1)! + let exactMidnightDay2 = Calendar.current.startOfDay(for: day2) + + DataController.shared.add(mood: .great, forDate: exactMidnightDay2, entryType: .listView) + + let fetchedForDay1 = DataController.shared.getEntry(byDate: day1) + + // This documents the boundary bug: <= endDate means midnight of next day matches. + // If this assertion fails in the future, it means the boundary bug was fixed (use < instead of <=). + XCTAssertNotNil( + fetchedForDay1, + "Boundary behavior: entry at exactly startOfDay+1 (midnight next day) matches due to <= predicate. " + + "If this fails, the boundary bug in getEntry was fixed -- update this test." + ) + } + + func testStreak_WithNoEntryForToday_CountsFromYesterday() { + // Add entries for 3 consecutive days (days 1, 2, 3 ago) but NOT today (day 0) + DataController.shared.add(mood: .good, forDate: date(daysAgo: 1), entryType: .listView) + DataController.shared.add(mood: .great, forDate: date(daysAgo: 2), entryType: .listView) + DataController.shared.add(mood: .average, forDate: date(daysAgo: 3), entryType: .listView) + + let result = DataController.shared.calculateStreak(from: date(daysAgo: 0)) + + XCTAssertEqual(result.streak, 3, "Streak should be 3 when days 1-3 have entries but today does not") + XCTAssertNil(result.todaysMood, "todaysMood should be nil when there is no entry for today") + } + + // MARK: - Pipeline 9: Voting Date Logic + + func testVotingDate_Today_AfterVotingTime() { + let onboarding = OnboardingData() + onboarding.inputDay = .Today + onboarding.date = makeDate(year: 2026, month: 2, day: 14, hour: 18, minute: 0) // voting time 6 PM + + let now = makeDate(year: 2026, month: 2, day: 14, hour: 20, minute: 0) // 8 PM, after voting time + + let votingDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: onboarding, now: now) + + XCTAssertTrue( + Calendar.current.isDate(votingDate, inSameDayAs: now), + "With inputDay=.Today and now after voting time, voting date should be today" + ) + } + + func testVotingDate_Today_BeforeVotingTime() { + let onboarding = OnboardingData() + onboarding.inputDay = .Today + onboarding.date = makeDate(year: 2026, month: 2, day: 14, hour: 18, minute: 0) // voting time 6 PM + + let now = makeDate(year: 2026, month: 2, day: 14, hour: 14, minute: 0) // 2 PM, before voting time + + let votingDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: onboarding, now: now) + + let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: now)! + XCTAssertTrue( + Calendar.current.isDate(votingDate, inSameDayAs: yesterday), + "With inputDay=.Today and now before voting time, voting date should be yesterday" + ) + } + + func testVotingDate_Previous_AfterVotingTime() { + let onboarding = OnboardingData() + onboarding.inputDay = .Previous + onboarding.date = makeDate(year: 2026, month: 2, day: 14, hour: 18, minute: 0) // voting time 6 PM + + let now = makeDate(year: 2026, month: 2, day: 14, hour: 20, minute: 0) // 8 PM, after voting time + + let votingDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: onboarding, now: now) + + let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: now)! + XCTAssertTrue( + Calendar.current.isDate(votingDate, inSameDayAs: yesterday), + "With inputDay=.Previous and now after voting time, voting date should be yesterday" + ) + } + + func testVotingDate_Previous_BeforeVotingTime() { + let onboarding = OnboardingData() + onboarding.inputDay = .Previous + onboarding.date = makeDate(year: 2026, month: 2, day: 14, hour: 18, minute: 0) // voting time 6 PM + + let now = makeDate(year: 2026, month: 2, day: 14, hour: 14, minute: 0) // 2 PM, before voting time + + let votingDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: onboarding, now: now) + + let twoDaysAgo = Calendar.current.date(byAdding: .day, value: -2, to: now)! + XCTAssertTrue( + Calendar.current.isDate(votingDate, inSameDayAs: twoDaysAgo), + "With inputDay=.Previous and now before voting time, voting date should be 2 days ago" + ) + } + + func testPassedVotingUnlock_ExactlyAtTime() { + let voteDate = makeDate(year: 2026, month: 2, day: 14, hour: 18, minute: 0) // 6:00 PM + let now = makeDate(year: 2026, month: 2, day: 14, hour: 18, minute: 0) // exactly 6:00 PM + + let result = ShowBasedOnVoteLogics.passedTodaysVotingUnlock(voteDate: voteDate, now: now) + + XCTAssertTrue( + result, + "passedTodaysVotingUnlock should return true when now is exactly at the voting time (>= check)" + ) + } +} diff --git a/Tests iOS/MoodLoggerIntegrationTests.swift b/Tests iOS/MoodLoggerIntegrationTests.swift new file mode 100644 index 0000000..8d9185e --- /dev/null +++ b/Tests iOS/MoodLoggerIntegrationTests.swift @@ -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") + } +} diff --git a/Tests iOS/Tests_iOS.swift b/Tests iOS/Tests_iOS.swift index 1456654..3d6750b 100644 --- a/Tests iOS/Tests_iOS.swift +++ b/Tests iOS/Tests_iOS.swift @@ -6,7 +6,7 @@ // import XCTest -@testable import iFeel +@testable import Feels class Tests_iOS: XCTestCase { override func setUpWithError() throws {