Revert "Add 57 tests covering all data mutation paths"

This reverts commit 4125c93dfe.
This commit is contained in:
Trey t
2026-02-14 23:32:19 -06:00
parent 4125c93dfe
commit 0e8738794b
5 changed files with 4 additions and 958 deletions

View File

@@ -13,9 +13,6 @@
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; };
@@ -75,9 +72,6 @@
1CD90AFD278C7DE0001C4FEA /* macOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = macOS.entitlements; sourceTree = "<group>"; };
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 = "<group>"; };
1CA1B00130000000001C4FEA /* DataListenerAndVotingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataListenerAndVotingTests.swift; sourceTree = "<group>"; };
1CA1B00130000002001C4FEA /* DataControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataControllerTests.swift; sourceTree = "<group>"; };
1CA1B00130000004001C4FEA /* MoodLoggerIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoodLoggerIntegrationTests.swift; sourceTree = "<group>"; };
1CD90B08278C7DE0001C4FEA /* Tests_iOSLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests_iOSLaunchTests.swift; sourceTree = "<group>"; };
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 = "<group>"; };
@@ -251,9 +245,6 @@
children = (
1CD90B06278C7DE0001C4FEA /* Tests_iOS.swift */,
1CD90B08278C7DE0001C4FEA /* Tests_iOSLaunchTests.swift */,
1CA1B00130000000001C4FEA /* DataListenerAndVotingTests.swift */,
1CA1B00130000002001C4FEA /* DataControllerTests.swift */,
1CA1B00130000004001C4FEA /* MoodLoggerIntegrationTests.swift */,
);
path = "Tests iOS";
sourceTree = "<group>";
@@ -340,7 +331,7 @@
name = "Tests iOS";
productName = "Tests iOS";
productReference = 1CD90B02278C7DE0001C4FEA /* Tests iOS.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
productType = "com.apple.product-type.bundle.ui-testing";
};
1CD90B0D278C7DE0001C4FEA /* Tests macOS */ = {
isa = PBXNativeTarget;
@@ -546,9 +537,6 @@
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;
};
@@ -893,11 +881,10 @@
1CD90B29278C7DE0001C4FEA /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.2;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "com.88oak.Tests-iOS";
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -905,7 +892,6 @@
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;
@@ -913,11 +899,10 @@
1CD90B2A278C7DE0001C4FEA /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.2;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "com.88oak.Tests-iOS";
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -925,7 +910,6 @@
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;
};

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,7 @@
//
import XCTest
@testable import Feels
@testable import iFeel
class Tests_iOS: XCTestCase {
override func setUpWithError() throws {