// // 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") } }