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