Rebrand entire project from Feels to Reflect
Complete rename across all bundle IDs, App Groups, CloudKit containers, StoreKit product IDs, data store filenames, URL schemes, logger subsystems, Swift identifiers, user-facing strings (7 languages), file names, directory names, Xcode project, schemes, assets, and documentation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
366
ReflectTests/DataControllerCRUDTests.swift
Normal file
366
ReflectTests/DataControllerCRUDTests.swift
Normal file
@@ -0,0 +1,366 @@
|
||||
//
|
||||
// DataControllerCRUDTests.swift
|
||||
// ReflectTests
|
||||
//
|
||||
// Tests for DataController create, read, update, and delete operations.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
import SwiftData
|
||||
@testable import Reflect
|
||||
|
||||
@MainActor
|
||||
final class DataControllerCreateTests: XCTestCase {
|
||||
var sut: DataController!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
let schema = Schema([MoodEntryModel.self])
|
||||
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
|
||||
sut = DataController(container: try! ModelContainer(for: schema, configurations: [config]))
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
sut = nil
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
// MARK: - Phase 2: Create Tests
|
||||
|
||||
func test_add_insertsEntry() {
|
||||
let date = makeDate(2024, 6, 15)
|
||||
sut.add(mood: .great, forDate: date, entryType: .listView)
|
||||
|
||||
let entry = sut.getEntry(byDate: date)
|
||||
XCTAssertNotNil(entry, "Entry should exist after add")
|
||||
XCTAssertEqual(entry?.mood, .great)
|
||||
XCTAssertEqual(entry?.entryType, EntryType.listView.rawValue)
|
||||
}
|
||||
|
||||
func test_add_replacesExistingForSameDate() {
|
||||
let date = makeDate(2024, 6, 15)
|
||||
sut.add(mood: .great, forDate: date, entryType: .listView)
|
||||
sut.add(mood: .bad, forDate: date, entryType: .widget)
|
||||
|
||||
let allEntries = sut.getAllEntries(byDate: date)
|
||||
XCTAssertEqual(allEntries.count, 1, "Should have exactly 1 entry after 2 adds for same date")
|
||||
XCTAssertEqual(allEntries.first?.mood, .bad, "Should keep the latest mood")
|
||||
}
|
||||
|
||||
func test_add_setsCorrectWeekday() {
|
||||
// June 15, 2024 is a Saturday (weekday = 7 in Gregorian)
|
||||
let date = makeDate(2024, 6, 15)
|
||||
sut.add(mood: .average, forDate: date, entryType: .listView)
|
||||
|
||||
let entry = sut.getEntry(byDate: date)
|
||||
let expectedWeekday = Calendar.current.component(.weekday, from: date)
|
||||
XCTAssertEqual(entry?.weekDay, expectedWeekday)
|
||||
}
|
||||
|
||||
func test_add_allMoodValues() {
|
||||
let moods: [Mood] = [.horrible, .bad, .average, .good, .great]
|
||||
for (i, mood) in moods.enumerated() {
|
||||
let date = makeDate(2024, 6, 10 + i)
|
||||
sut.add(mood: mood, forDate: date, entryType: .listView)
|
||||
|
||||
let entry = sut.getEntry(byDate: date)
|
||||
XCTAssertEqual(entry?.mood, mood, "Mood \(mood) should persist and read back correctly")
|
||||
}
|
||||
}
|
||||
|
||||
func test_addBatch_insertsMultiple() {
|
||||
let entries: [(mood: Mood, date: Date, entryType: EntryType)] = [
|
||||
(.great, makeDate(2024, 6, 10), .listView),
|
||||
(.good, makeDate(2024, 6, 11), .widget),
|
||||
(.average, makeDate(2024, 6, 12), .watch),
|
||||
]
|
||||
sut.addBatch(entries: entries)
|
||||
|
||||
for (mood, date, _) in entries {
|
||||
let entry = sut.getEntry(byDate: date)
|
||||
XCTAssertNotNil(entry, "Entry for \(date) should exist")
|
||||
XCTAssertEqual(entry?.mood, mood)
|
||||
}
|
||||
}
|
||||
|
||||
func test_addBatch_replacesExisting() {
|
||||
let date = makeDate(2024, 6, 10)
|
||||
sut.add(mood: .horrible, forDate: date, entryType: .listView)
|
||||
|
||||
let batchEntries: [(mood: Mood, date: Date, entryType: EntryType)] = [
|
||||
(.great, date, .widget),
|
||||
]
|
||||
sut.addBatch(entries: batchEntries)
|
||||
|
||||
let allEntries = sut.getAllEntries(byDate: date)
|
||||
XCTAssertEqual(allEntries.count, 1)
|
||||
XCTAssertEqual(allEntries.first?.mood, .great)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Phase 3: Read Tests
|
||||
|
||||
@MainActor
|
||||
final class DataControllerReadTests: XCTestCase {
|
||||
var sut: DataController!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
let schema = Schema([MoodEntryModel.self])
|
||||
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
|
||||
sut = DataController(container: try! ModelContainer(for: schema, configurations: [config]))
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
sut = nil
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func test_getEntry_emptyDB_returnsNil() {
|
||||
let entry = sut.getEntry(byDate: Date())
|
||||
XCTAssertNil(entry)
|
||||
}
|
||||
|
||||
func test_getEntry_boundaryExclusion() {
|
||||
// Insert entry at exactly midnight of Jan 2 (start of Jan 2)
|
||||
// Use hour=0 to place it precisely at the boundary
|
||||
let jan2Midnight = makeDate(2024, 1, 2, hour: 0)
|
||||
let entry = MoodEntryModel(forDate: jan2Midnight, mood: .great, entryType: .listView)
|
||||
sut.modelContext.insert(entry)
|
||||
sut.save()
|
||||
|
||||
// Query for Jan 1 — should NOT find the Jan 2 entry
|
||||
// getEntry builds range [Jan 1 00:00, Jan 2 00:00] — with <= the midnight entry leaks in
|
||||
let jan1 = makeDate(2024, 1, 1)
|
||||
let result = sut.getEntry(byDate: jan1)
|
||||
XCTAssertNil(result, "Entry at midnight Jan 2 should NOT be returned when querying Jan 1")
|
||||
}
|
||||
|
||||
func test_getData_filtersWeekdays() {
|
||||
// Add entries for a full week (Mon-Sun)
|
||||
// Jan 1, 2024 is a Monday (weekday=2)
|
||||
for i in 0..<7 {
|
||||
let date = Calendar.current.date(byAdding: .day, value: i, to: makeDate(2024, 1, 1))!
|
||||
sut.add(mood: .average, forDate: date, entryType: .listView)
|
||||
}
|
||||
|
||||
// Filter to only weekdays 2 (Mon) and 6 (Fri)
|
||||
let startDate = makeDate(2024, 1, 1)
|
||||
let endDate = makeDate(2024, 1, 7)
|
||||
let results = sut.getData(startDate: startDate, endDate: endDate, includedDays: [2, 6])
|
||||
|
||||
XCTAssertEqual(results.count, 2, "Should return only Monday and Friday")
|
||||
}
|
||||
|
||||
func test_getData_emptyIncludedDays_returnsAll() {
|
||||
for i in 0..<7 {
|
||||
let date = Calendar.current.date(byAdding: .day, value: i, to: makeDate(2024, 1, 1))!
|
||||
sut.add(mood: .average, forDate: date, entryType: .listView)
|
||||
}
|
||||
|
||||
let startDate = makeDate(2024, 1, 1)
|
||||
let endDate = makeDate(2024, 1, 7)
|
||||
let results = sut.getData(startDate: startDate, endDate: endDate, includedDays: [])
|
||||
|
||||
XCTAssertEqual(results.count, 7, "Empty includedDays should return all 7 entries")
|
||||
}
|
||||
|
||||
func test_splitIntoYearMonth_groupsCorrectly() {
|
||||
// Add entries in different months
|
||||
sut.add(mood: .great, forDate: makeDate(2024, 1, 15), entryType: .listView)
|
||||
sut.add(mood: .good, forDate: makeDate(2024, 1, 20), entryType: .listView)
|
||||
sut.add(mood: .bad, forDate: makeDate(2024, 3, 5), entryType: .listView)
|
||||
|
||||
let grouped = sut.splitIntoYearMonth(includedDays: [])
|
||||
XCTAssertEqual(grouped[2024]?[1]?.count, 2, "Jan 2024 should have 2 entries")
|
||||
XCTAssertEqual(grouped[2024]?[3]?.count, 1, "Mar 2024 should have 1 entry")
|
||||
XCTAssertNil(grouped[2024]?[2], "Feb 2024 should have no entries")
|
||||
}
|
||||
|
||||
func test_earliestEntry() {
|
||||
sut.add(mood: .great, forDate: makeDate(2024, 6, 15), entryType: .listView)
|
||||
sut.add(mood: .bad, forDate: makeDate(2024, 1, 1), entryType: .listView)
|
||||
sut.add(mood: .good, forDate: makeDate(2024, 3, 10), entryType: .listView)
|
||||
|
||||
let earliest = sut.earliestEntry
|
||||
XCTAssertNotNil(earliest)
|
||||
XCTAssertTrue(Calendar.current.isDate(earliest!.forDate, inSameDayAs: makeDate(2024, 1, 1)))
|
||||
}
|
||||
|
||||
func test_latestEntry() {
|
||||
sut.add(mood: .great, forDate: makeDate(2024, 6, 15), entryType: .listView)
|
||||
sut.add(mood: .bad, forDate: makeDate(2024, 1, 1), entryType: .listView)
|
||||
sut.add(mood: .good, forDate: makeDate(2024, 3, 10), entryType: .listView)
|
||||
|
||||
let latest = sut.latestEntry
|
||||
XCTAssertNotNil(latest)
|
||||
XCTAssertTrue(Calendar.current.isDate(latest!.forDate, inSameDayAs: makeDate(2024, 6, 15)))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Phase 4: Update Tests
|
||||
|
||||
@MainActor
|
||||
final class DataControllerUpdateTests: XCTestCase {
|
||||
var sut: DataController!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
let schema = Schema([MoodEntryModel.self])
|
||||
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
|
||||
sut = DataController(container: try! ModelContainer(for: schema, configurations: [config]))
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
sut = nil
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func test_update_changesMood() {
|
||||
let date = makeDate(2024, 6, 15)
|
||||
sut.add(mood: .bad, forDate: date, entryType: .listView)
|
||||
|
||||
let result = sut.update(entryDate: date, withMood: .great)
|
||||
XCTAssertTrue(result)
|
||||
|
||||
let entry = sut.getEntry(byDate: date)
|
||||
XCTAssertEqual(entry?.mood, .great)
|
||||
}
|
||||
|
||||
func test_update_nonexistent_returnsFalse() {
|
||||
let result = sut.update(entryDate: makeDate(2024, 6, 15), withMood: .great)
|
||||
XCTAssertFalse(result)
|
||||
}
|
||||
|
||||
func test_updateNotes_setsNotes() {
|
||||
let date = makeDate(2024, 6, 15)
|
||||
sut.add(mood: .good, forDate: date, entryType: .listView)
|
||||
|
||||
let result = sut.updateNotes(forDate: date, notes: "Had a great day")
|
||||
XCTAssertTrue(result)
|
||||
|
||||
let entry = sut.getEntry(byDate: date)
|
||||
XCTAssertEqual(entry?.notes, "Had a great day")
|
||||
}
|
||||
|
||||
func test_updateNotes_nilClearsNotes() {
|
||||
let date = makeDate(2024, 6, 15)
|
||||
sut.add(mood: .good, forDate: date, entryType: .listView)
|
||||
sut.updateNotes(forDate: date, notes: "Some notes")
|
||||
|
||||
let result = sut.updateNotes(forDate: date, notes: nil)
|
||||
XCTAssertTrue(result)
|
||||
|
||||
let entry = sut.getEntry(byDate: date)
|
||||
XCTAssertNil(entry?.notes)
|
||||
}
|
||||
|
||||
func test_updatePhoto_setsPhotoID() {
|
||||
let date = makeDate(2024, 6, 15)
|
||||
sut.add(mood: .good, forDate: date, entryType: .listView)
|
||||
|
||||
let photoID = UUID()
|
||||
let result = sut.updatePhoto(forDate: date, photoID: photoID)
|
||||
XCTAssertTrue(result)
|
||||
|
||||
let entry = sut.getEntry(byDate: date)
|
||||
XCTAssertEqual(entry?.photoID, photoID)
|
||||
}
|
||||
|
||||
func test_update_refreshesTimestamp() {
|
||||
let date = makeDate(2024, 6, 15)
|
||||
sut.add(mood: .bad, forDate: date, entryType: .listView)
|
||||
|
||||
let originalTimestamp = sut.getEntry(byDate: date)!.timestamp
|
||||
|
||||
// Small delay so timestamp would differ
|
||||
let result = sut.update(entryDate: date, withMood: .great)
|
||||
XCTAssertTrue(result)
|
||||
|
||||
let updatedEntry = sut.getEntry(byDate: date)!
|
||||
XCTAssertGreaterThanOrEqual(updatedEntry.timestamp, originalTimestamp,
|
||||
"Timestamp should be updated on mood change so removeDuplicates picks the right entry")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Phase 5: Delete Tests
|
||||
|
||||
@MainActor
|
||||
final class DataControllerDeleteTests: XCTestCase {
|
||||
var sut: DataController!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
let schema = Schema([MoodEntryModel.self])
|
||||
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
|
||||
sut = DataController(container: try! ModelContainer(for: schema, configurations: [config]))
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
sut = nil
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func test_clearDB_removesAll() {
|
||||
sut.add(mood: .great, forDate: makeDate(2024, 6, 10), entryType: .listView)
|
||||
sut.add(mood: .bad, forDate: makeDate(2024, 6, 11), entryType: .listView)
|
||||
sut.add(mood: .good, forDate: makeDate(2024, 6, 12), entryType: .listView)
|
||||
|
||||
sut.clearDB()
|
||||
|
||||
let all = sut.getData(startDate: Date(timeIntervalSince1970: 0), endDate: Date(), includedDays: [])
|
||||
XCTAssertTrue(all.isEmpty, "clearDB should remove all entries")
|
||||
}
|
||||
|
||||
func test_deleteAllEntries_removesOnlyTargetDate() {
|
||||
let date1 = makeDate(2024, 6, 10)
|
||||
let date2 = makeDate(2024, 6, 11)
|
||||
sut.add(mood: .great, forDate: date1, entryType: .listView)
|
||||
sut.add(mood: .bad, forDate: date2, entryType: .listView)
|
||||
|
||||
sut.deleteAllEntries(forDate: date1)
|
||||
|
||||
XCTAssertNil(sut.getEntry(byDate: date1), "Deleted date should be gone")
|
||||
XCTAssertNotNil(sut.getEntry(byDate: date2), "Other dates should be untouched")
|
||||
}
|
||||
|
||||
func test_removeDuplicates_keepsBestEntry() {
|
||||
let date = makeDate(2024, 6, 15)
|
||||
|
||||
// Manually insert 3 entries for the same date to simulate CloudKit conflict
|
||||
let entry1 = MoodEntryModel(forDate: date, mood: .missing, entryType: .filledInMissing)
|
||||
entry1.timestamp = makeDate(2024, 6, 15, hour: 8)
|
||||
sut.modelContext.insert(entry1)
|
||||
|
||||
let entry2 = MoodEntryModel(forDate: date, mood: .great, entryType: .listView)
|
||||
entry2.timestamp = makeDate(2024, 6, 15, hour: 10)
|
||||
sut.modelContext.insert(entry2)
|
||||
|
||||
let entry3 = MoodEntryModel(forDate: date, mood: .good, entryType: .listView)
|
||||
entry3.timestamp = makeDate(2024, 6, 15, hour: 9)
|
||||
sut.modelContext.insert(entry3)
|
||||
|
||||
sut.save()
|
||||
|
||||
let removed = sut.removeDuplicates()
|
||||
XCTAssertEqual(removed, 2, "Should remove 2 duplicates")
|
||||
|
||||
let remaining = sut.getAllEntries(byDate: date)
|
||||
XCTAssertEqual(remaining.count, 1, "Should keep exactly 1 entry")
|
||||
// Should keep non-missing with most recent timestamp (entry2, .great at 10am)
|
||||
XCTAssertEqual(remaining.first?.mood, .great, "Should keep the best entry (non-missing, most recent)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Test Helpers
|
||||
|
||||
func makeDate(_ year: Int, _ month: Int, _ day: Int, hour: Int = 12) -> Date {
|
||||
var components = DateComponents()
|
||||
components.year = year
|
||||
components.month = month
|
||||
components.day = day
|
||||
components.hour = hour
|
||||
components.minute = 0
|
||||
components.second = 0
|
||||
return Calendar.current.date(from: components)!
|
||||
}
|
||||
42
ReflectTests/DayViewViewModelTests.swift
Normal file
42
ReflectTests/DayViewViewModelTests.swift
Normal file
@@ -0,0 +1,42 @@
|
||||
//
|
||||
// DayViewViewModelTests.swift
|
||||
// ReflectTests
|
||||
//
|
||||
// Unit tests for DayViewViewModel.countEntries — verifies safe counting
|
||||
// across various grouped dictionary shapes (TDD for force-unwrap fix).
|
||||
//
|
||||
|
||||
import XCTest
|
||||
import SwiftData
|
||||
@testable import Reflect
|
||||
|
||||
@MainActor
|
||||
final class DayViewViewModelTests: XCTestCase {
|
||||
|
||||
func testCountEntries_EmptyGrouped() {
|
||||
let grouped: [Int: [Int: [MoodEntryModel]]] = [:]
|
||||
XCTAssertEqual(DayViewViewModel.countEntries(in: grouped), 0)
|
||||
}
|
||||
|
||||
func testCountEntries_SingleYearSingleMonth() {
|
||||
let entry = MoodEntryModel(forDate: Date(), mood: .great, entryType: .listView)
|
||||
let grouped: [Int: [Int: [MoodEntryModel]]] = [2026: [2: [entry]]]
|
||||
XCTAssertEqual(DayViewViewModel.countEntries(in: grouped), 1)
|
||||
}
|
||||
|
||||
func testCountEntries_MultipleYearsAndMonths() {
|
||||
let e1 = MoodEntryModel(forDate: Date(), mood: .great, entryType: .listView)
|
||||
let e2 = MoodEntryModel(forDate: Date(), mood: .good, entryType: .listView)
|
||||
let e3 = MoodEntryModel(forDate: Date(), mood: .bad, entryType: .listView)
|
||||
let grouped: [Int: [Int: [MoodEntryModel]]] = [
|
||||
2025: [12: [e1, e2]],
|
||||
2026: [1: [e3], 2: [e1, e2, e3]]
|
||||
]
|
||||
XCTAssertEqual(DayViewViewModel.countEntries(in: grouped), 6)
|
||||
}
|
||||
|
||||
func testCountEntries_YearWithEmptyMonth() {
|
||||
let grouped: [Int: [Int: [MoodEntryModel]]] = [2026: [1: []]]
|
||||
XCTAssertEqual(DayViewViewModel.countEntries(in: grouped), 0)
|
||||
}
|
||||
}
|
||||
141
ReflectTests/IntegrationTests.swift
Normal file
141
ReflectTests/IntegrationTests.swift
Normal file
@@ -0,0 +1,141 @@
|
||||
//
|
||||
// IntegrationTests.swift
|
||||
// ReflectTests
|
||||
//
|
||||
// Integration tests verifying full lifecycle pipelines.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
import SwiftData
|
||||
@testable import Reflect
|
||||
|
||||
@MainActor
|
||||
final class IntegrationTests: XCTestCase {
|
||||
var sut: DataController!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
let schema = Schema([MoodEntryModel.self])
|
||||
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
|
||||
sut = DataController(container: try! ModelContainer(for: schema, configurations: [config]))
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
sut = nil
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
// MARK: - Phase 8: Integration Pipelines
|
||||
|
||||
func test_fullCRUD_lifecycle() {
|
||||
let date = makeDate(2024, 6, 15)
|
||||
|
||||
// Create
|
||||
sut.add(mood: .good, forDate: date, entryType: .listView)
|
||||
XCTAssertNotNil(sut.getEntry(byDate: date))
|
||||
|
||||
// Read
|
||||
let entry = sut.getEntry(byDate: date)
|
||||
XCTAssertEqual(entry?.mood, .good)
|
||||
|
||||
// Update
|
||||
let updated = sut.update(entryDate: date, withMood: .great)
|
||||
XCTAssertTrue(updated)
|
||||
XCTAssertEqual(sut.getEntry(byDate: date)?.mood, .great)
|
||||
|
||||
// Update notes
|
||||
sut.updateNotes(forDate: date, notes: "Lifecycle test")
|
||||
XCTAssertEqual(sut.getEntry(byDate: date)?.notes, "Lifecycle test")
|
||||
|
||||
// Delete
|
||||
sut.deleteAllEntries(forDate: date)
|
||||
XCTAssertNil(sut.getEntry(byDate: date))
|
||||
}
|
||||
|
||||
func test_streak_throughLifecycle() {
|
||||
let today = Calendar.current.startOfDay(for: Date())
|
||||
let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: today)!
|
||||
|
||||
// Add today → streak=1
|
||||
sut.add(mood: .good, forDate: today, entryType: .listView)
|
||||
XCTAssertEqual(sut.calculateStreak(from: today).streak, 1)
|
||||
|
||||
// Add yesterday → streak=2
|
||||
sut.add(mood: .great, forDate: yesterday, entryType: .listView)
|
||||
XCTAssertEqual(sut.calculateStreak(from: today).streak, 2)
|
||||
|
||||
// Update today's mood → streak still 2
|
||||
sut.update(entryDate: today, withMood: .average)
|
||||
XCTAssertEqual(sut.calculateStreak(from: today).streak, 2)
|
||||
|
||||
// Delete today → streak=1 (counting from yesterday)
|
||||
sut.deleteAllEntries(forDate: today)
|
||||
XCTAssertEqual(sut.calculateStreak(from: today).streak, 1)
|
||||
}
|
||||
|
||||
func test_voteStatus_throughLifecycle() {
|
||||
let onboarding = OnboardingData()
|
||||
onboarding.inputDay = .Today
|
||||
// Set unlock time to 00:01 so we're always "after" unlock
|
||||
var components = Calendar.current.dateComponents([.year, .month, .day], from: Date())
|
||||
components.hour = 0
|
||||
components.minute = 1
|
||||
onboarding.date = Calendar.current.date(from: components)!
|
||||
|
||||
let votingDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: onboarding)
|
||||
|
||||
// Helper: check vote status using testable DataController
|
||||
func isVoteNeeded() -> Bool {
|
||||
let entry = sut.getEntry(byDate: votingDate.startOfDay)
|
||||
return entry == nil || entry?.mood == .missing
|
||||
}
|
||||
|
||||
// No entry → vote needed
|
||||
XCTAssertTrue(isVoteNeeded())
|
||||
|
||||
// Add entry → no vote needed
|
||||
sut.add(mood: .great, forDate: votingDate, entryType: .listView)
|
||||
XCTAssertFalse(isVoteNeeded())
|
||||
|
||||
// Delete entry → vote needed again
|
||||
sut.deleteAllEntries(forDate: votingDate)
|
||||
XCTAssertTrue(isVoteNeeded())
|
||||
}
|
||||
|
||||
func test_duplicateRemoval() {
|
||||
let date = makeDate(2024, 6, 15)
|
||||
|
||||
// Manually insert 3 entries for same date (simulating CloudKit conflict)
|
||||
let entry1 = MoodEntryModel(forDate: date, mood: .great, entryType: .listView)
|
||||
entry1.timestamp = makeDate(2024, 6, 15, hour: 10)
|
||||
sut.modelContext.insert(entry1)
|
||||
|
||||
let entry2 = MoodEntryModel(forDate: date, mood: .good, entryType: .listView)
|
||||
entry2.timestamp = makeDate(2024, 6, 15, hour: 8)
|
||||
sut.modelContext.insert(entry2)
|
||||
|
||||
let entry3 = MoodEntryModel(forDate: date, mood: .missing, entryType: .filledInMissing)
|
||||
entry3.timestamp = makeDate(2024, 6, 15, hour: 12)
|
||||
sut.modelContext.insert(entry3)
|
||||
|
||||
sut.save()
|
||||
|
||||
let removed = sut.removeDuplicates()
|
||||
XCTAssertEqual(removed, 2)
|
||||
XCTAssertEqual(sut.getAllEntries(byDate: date).count, 1)
|
||||
}
|
||||
|
||||
func test_dataListeners_fireOnMutation() {
|
||||
var listenerCallCount = 0
|
||||
sut.addNewDataListener {
|
||||
listenerCallCount += 1
|
||||
}
|
||||
|
||||
// add() calls saveAndRunDataListeners internally
|
||||
sut.add(mood: .good, forDate: makeDate(2024, 6, 15), entryType: .listView)
|
||||
|
||||
// add() does: delete-save (if existing) + insert + saveAndRunDataListeners
|
||||
// For a fresh add with no existing entry, listener fires once from saveAndRunDataListeners
|
||||
XCTAssertGreaterThanOrEqual(listenerCallCount, 1, "Listener should fire at least once on mutation")
|
||||
}
|
||||
}
|
||||
125
ReflectTests/StreakTests.swift
Normal file
125
ReflectTests/StreakTests.swift
Normal file
@@ -0,0 +1,125 @@
|
||||
//
|
||||
// StreakTests.swift
|
||||
// ReflectTests
|
||||
//
|
||||
// Tests for DataController streak calculation.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
import SwiftData
|
||||
@testable import Reflect
|
||||
|
||||
@MainActor
|
||||
final class StreakTests: XCTestCase {
|
||||
var sut: DataController!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
let schema = Schema([MoodEntryModel.self])
|
||||
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
|
||||
sut = DataController(container: try! ModelContainer(for: schema, configurations: [config]))
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
sut = nil
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
// MARK: - Phase 6: Streak Calculation
|
||||
|
||||
func test_streak_emptyDB_zero() {
|
||||
let result = sut.calculateStreak(from: Date())
|
||||
XCTAssertEqual(result.streak, 0)
|
||||
XCTAssertNil(result.todaysMood)
|
||||
}
|
||||
|
||||
func test_streak_singleEntryToday_one() {
|
||||
let today = Calendar.current.startOfDay(for: Date())
|
||||
sut.add(mood: .great, forDate: today, entryType: .listView)
|
||||
|
||||
let result = sut.calculateStreak(from: today)
|
||||
XCTAssertEqual(result.streak, 1)
|
||||
XCTAssertEqual(result.todaysMood, .great)
|
||||
}
|
||||
|
||||
func test_streak_consecutiveDays() {
|
||||
let today = Calendar.current.startOfDay(for: Date())
|
||||
for i in 0..<5 {
|
||||
let date = Calendar.current.date(byAdding: .day, value: -i, to: today)!
|
||||
sut.add(mood: .good, forDate: date, entryType: .listView)
|
||||
}
|
||||
|
||||
let result = sut.calculateStreak(from: today)
|
||||
XCTAssertEqual(result.streak, 5)
|
||||
}
|
||||
|
||||
func test_streak_gapBreaks() {
|
||||
let today = Calendar.current.startOfDay(for: Date())
|
||||
// Today, yesterday, then skip a day, then 2 more
|
||||
sut.add(mood: .good, forDate: today, entryType: .listView)
|
||||
sut.add(mood: .good, forDate: Calendar.current.date(byAdding: .day, value: -1, to: today)!, entryType: .listView)
|
||||
// Skip day -2
|
||||
sut.add(mood: .good, forDate: Calendar.current.date(byAdding: .day, value: -3, to: today)!, entryType: .listView)
|
||||
sut.add(mood: .good, forDate: Calendar.current.date(byAdding: .day, value: -4, to: today)!, entryType: .listView)
|
||||
|
||||
let result = sut.calculateStreak(from: today)
|
||||
XCTAssertEqual(result.streak, 2, "Streak should stop at the gap")
|
||||
}
|
||||
|
||||
func test_streak_missingDoesNotCount() {
|
||||
let today = Calendar.current.startOfDay(for: Date())
|
||||
sut.add(mood: .good, forDate: today, entryType: .listView)
|
||||
// Yesterday is a .missing entry (should be ignored)
|
||||
sut.add(mood: .missing, forDate: Calendar.current.date(byAdding: .day, value: -1, to: today)!, entryType: .filledInMissing)
|
||||
sut.add(mood: .good, forDate: Calendar.current.date(byAdding: .day, value: -2, to: today)!, entryType: .listView)
|
||||
|
||||
let result = sut.calculateStreak(from: today)
|
||||
XCTAssertEqual(result.streak, 1, ".missing entries should not count toward streak")
|
||||
}
|
||||
|
||||
func test_streak_noEntryToday_countsFromYesterday() {
|
||||
let today = Calendar.current.startOfDay(for: Date())
|
||||
let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: today)!
|
||||
let twoDaysAgo = Calendar.current.date(byAdding: .day, value: -2, to: today)!
|
||||
|
||||
sut.add(mood: .good, forDate: yesterday, entryType: .listView)
|
||||
sut.add(mood: .great, forDate: twoDaysAgo, entryType: .listView)
|
||||
|
||||
let result = sut.calculateStreak(from: today)
|
||||
XCTAssertEqual(result.streak, 2, "Should count from yesterday when today has no entry")
|
||||
XCTAssertNil(result.todaysMood, "Today's mood should be nil when no entry exists")
|
||||
}
|
||||
|
||||
func test_streak_entryAfterVotingTime_stillCounts() {
|
||||
// Bug: calculateStreak passes votingDate (with time) as endDate to getData.
|
||||
// getData uses <=, so an entry logged AFTER votingDate's time is excluded.
|
||||
// E.g. if votingDate is "today at 8am" and user logged at 10am, the 10am entry
|
||||
// is excluded from the query, making streak miss today.
|
||||
let today = Calendar.current.startOfDay(for: Date())
|
||||
let morningVotingDate = Calendar.current.date(byAdding: .hour, value: 8, to: today)!
|
||||
|
||||
// Add entry at 10am (after the 8am voting date time)
|
||||
let afternoonDate = Calendar.current.date(byAdding: .hour, value: 10, to: today)!
|
||||
sut.add(mood: .great, forDate: afternoonDate, entryType: .listView)
|
||||
|
||||
let result = sut.calculateStreak(from: morningVotingDate)
|
||||
XCTAssertEqual(result.streak, 1, "Entry logged after votingDate time should still count")
|
||||
XCTAssertEqual(result.todaysMood, .great, "Today's mood should be found even if logged after votingDate time")
|
||||
}
|
||||
|
||||
func test_streak_afterDelete_decreases() {
|
||||
let today = Calendar.current.startOfDay(for: Date())
|
||||
let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: today)!
|
||||
|
||||
sut.add(mood: .good, forDate: today, entryType: .listView)
|
||||
sut.add(mood: .great, forDate: yesterday, entryType: .listView)
|
||||
|
||||
let beforeDelete = sut.calculateStreak(from: today)
|
||||
XCTAssertEqual(beforeDelete.streak, 2)
|
||||
|
||||
sut.deleteAllEntries(forDate: yesterday)
|
||||
|
||||
let afterDelete = sut.calculateStreak(from: today)
|
||||
XCTAssertEqual(afterDelete.streak, 1, "Streak should decrease after deleting a day")
|
||||
}
|
||||
}
|
||||
157
ReflectTests/VoteLogicsTests.swift
Normal file
157
ReflectTests/VoteLogicsTests.swift
Normal file
@@ -0,0 +1,157 @@
|
||||
//
|
||||
// VoteLogicsTests.swift
|
||||
// ReflectTests
|
||||
//
|
||||
// Tests for ShowBasedOnVoteLogics vote status and timing.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
import SwiftData
|
||||
@testable import Reflect
|
||||
|
||||
@MainActor
|
||||
final class VoteLogicsTests: XCTestCase {
|
||||
|
||||
// MARK: - Phase 7: passedTodaysVotingUnlock Tests
|
||||
|
||||
func test_passedVotingUnlock_beforeTime() {
|
||||
// Voting unlock set to 6:00 PM, current time is 2:00 PM
|
||||
let voteDate = makeDateWithTime(hour: 18, minute: 0)
|
||||
let now = makeDateWithTime(hour: 14, minute: 0)
|
||||
|
||||
let result = ShowBasedOnVoteLogics.passedTodaysVotingUnlock(voteDate: voteDate, now: now)
|
||||
XCTAssertFalse(result, "Should not have passed unlock when current time is before vote time")
|
||||
}
|
||||
|
||||
func test_passedVotingUnlock_afterTime() {
|
||||
// Voting unlock set to 6:00 PM, current time is 8:00 PM
|
||||
let voteDate = makeDateWithTime(hour: 18, minute: 0)
|
||||
let now = makeDateWithTime(hour: 20, minute: 0)
|
||||
|
||||
let result = ShowBasedOnVoteLogics.passedTodaysVotingUnlock(voteDate: voteDate, now: now)
|
||||
XCTAssertTrue(result, "Should have passed unlock when current time is after vote time")
|
||||
}
|
||||
|
||||
func test_passedVotingUnlock_exactTime() {
|
||||
// Voting unlock set to 6:00 PM, current time is exactly 6:00 PM
|
||||
let voteDate = makeDateWithTime(hour: 18, minute: 0)
|
||||
let now = makeDateWithTime(hour: 18, minute: 0)
|
||||
|
||||
let result = ShowBasedOnVoteLogics.passedTodaysVotingUnlock(voteDate: voteDate, now: now)
|
||||
XCTAssertTrue(result, "Should have passed unlock at exact vote time (uses >=)")
|
||||
}
|
||||
|
||||
// MARK: - Phase 7: getCurrentVotingDate Tests
|
||||
|
||||
func test_votingDate_today_beforeTime() {
|
||||
let onboarding = OnboardingData()
|
||||
onboarding.inputDay = .Today
|
||||
onboarding.date = makeDateWithTime(hour: 18, minute: 0)
|
||||
|
||||
let now = makeDateWithTime(hour: 14, minute: 0) // before 6pm
|
||||
let result = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: onboarding, now: now)
|
||||
|
||||
let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: now)!
|
||||
XCTAssertTrue(Calendar.current.isDate(result, inSameDayAs: yesterday),
|
||||
"Today mode, before unlock: should return yesterday")
|
||||
}
|
||||
|
||||
func test_votingDate_today_afterTime() {
|
||||
let onboarding = OnboardingData()
|
||||
onboarding.inputDay = .Today
|
||||
onboarding.date = makeDateWithTime(hour: 18, minute: 0)
|
||||
|
||||
let now = makeDateWithTime(hour: 20, minute: 0) // after 6pm
|
||||
let result = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: onboarding, now: now)
|
||||
|
||||
XCTAssertTrue(Calendar.current.isDate(result, inSameDayAs: now),
|
||||
"Today mode, after unlock: should return today")
|
||||
}
|
||||
|
||||
func test_votingDate_previous_beforeTime() {
|
||||
let onboarding = OnboardingData()
|
||||
onboarding.inputDay = .Previous
|
||||
onboarding.date = makeDateWithTime(hour: 18, minute: 0)
|
||||
|
||||
let now = makeDateWithTime(hour: 14, minute: 0) // before 6pm
|
||||
let result = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: onboarding, now: now)
|
||||
|
||||
let twoDaysAgo = Calendar.current.date(byAdding: .day, value: -2, to: now)!
|
||||
XCTAssertTrue(Calendar.current.isDate(result, inSameDayAs: twoDaysAgo),
|
||||
"Previous mode, before unlock: should return 2 days ago")
|
||||
}
|
||||
|
||||
func test_votingDate_previous_afterTime() {
|
||||
let onboarding = OnboardingData()
|
||||
onboarding.inputDay = .Previous
|
||||
onboarding.date = makeDateWithTime(hour: 18, minute: 0)
|
||||
|
||||
let now = makeDateWithTime(hour: 20, minute: 0) // after 6pm
|
||||
let result = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: onboarding, now: now)
|
||||
|
||||
let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: now)!
|
||||
XCTAssertTrue(Calendar.current.isDate(result, inSameDayAs: yesterday),
|
||||
"Previous mode, after unlock: should return yesterday")
|
||||
}
|
||||
|
||||
// MARK: - Phase 7: Vote Needed Tests (exercising the same logic as isMissingCurrentVote)
|
||||
// Note: isMissingCurrentVote uses DataController.shared (singleton) internally,
|
||||
// so we test the equivalent logic using a testable DataController instance.
|
||||
|
||||
func test_voteNeeded_noEntry() {
|
||||
let sut = makeTestDataController()
|
||||
let onboarding = OnboardingData()
|
||||
onboarding.inputDay = .Today
|
||||
onboarding.date = makeDateWithTime(hour: 0, minute: 1)
|
||||
|
||||
let votingDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: onboarding)
|
||||
let entry = sut.getEntry(byDate: votingDate.startOfDay)
|
||||
let isMissing = entry == nil || entry?.mood == .missing
|
||||
XCTAssertTrue(isMissing, "Should need a vote when no entry exists")
|
||||
}
|
||||
|
||||
func test_voteNeeded_validEntry() {
|
||||
let sut = makeTestDataController()
|
||||
let onboarding = OnboardingData()
|
||||
onboarding.inputDay = .Today
|
||||
onboarding.date = makeDateWithTime(hour: 0, minute: 1)
|
||||
|
||||
let votingDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: onboarding)
|
||||
sut.add(mood: .great, forDate: votingDate, entryType: .listView)
|
||||
|
||||
let entry = sut.getEntry(byDate: votingDate.startOfDay)
|
||||
let isMissing = entry == nil || entry?.mood == .missing
|
||||
XCTAssertFalse(isMissing, "Should not need a vote when valid entry exists")
|
||||
}
|
||||
|
||||
func test_voteNeeded_missingEntry() {
|
||||
let sut = makeTestDataController()
|
||||
let onboarding = OnboardingData()
|
||||
onboarding.inputDay = .Today
|
||||
onboarding.date = makeDateWithTime(hour: 0, minute: 1)
|
||||
|
||||
let votingDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: onboarding)
|
||||
sut.add(mood: .missing, forDate: votingDate, entryType: .filledInMissing)
|
||||
|
||||
let entry = sut.getEntry(byDate: votingDate.startOfDay)
|
||||
let isMissing = entry == nil || entry?.mood == .missing
|
||||
XCTAssertTrue(isMissing, "Should need a vote when entry is .missing")
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func makeTestDataController() -> DataController {
|
||||
let schema = Schema([MoodEntryModel.self])
|
||||
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
|
||||
return DataController(container: try! ModelContainer(for: schema, configurations: [config]))
|
||||
}
|
||||
|
||||
private func makeDateWithTime(hour: Int, minute: Int) -> Date {
|
||||
let calendar = Calendar.current
|
||||
var components = calendar.dateComponents([.year, .month, .day], from: Date())
|
||||
components.hour = hour
|
||||
components.minute = minute
|
||||
components.second = 0
|
||||
return calendar.date(from: components)!
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user