2026-07-05 15:05:14 +03:00
|
|
|
import XCTest
|
|
|
|
|
@testable import KisaniCal
|
|
|
|
|
|
|
|
|
|
final class StreakLogicTests: XCTestCase {
|
|
|
|
|
|
|
|
|
|
private let cal = Calendar.current
|
|
|
|
|
private let fmt: DateFormatter = {
|
|
|
|
|
let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; return f
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
private func day(_ offset: Int, from ref: Date) -> String {
|
|
|
|
|
fmt.string(from: cal.date(byAdding: .day, value: offset, to: ref)!)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-05 19:51:46 +03:00
|
|
|
// MARK: - Workout tally (lifetime; only ever grows, never resets)
|
2026-07-05 15:05:14 +03:00
|
|
|
|
2026-07-05 19:51:46 +03:00
|
|
|
/// The field scenario: 6-day week, Thursday skipped, 5 workouts done →
|
|
|
|
|
/// the count is 5, not 1. A missed day never reduces it.
|
|
|
|
|
func testSkippedDayStillCountsAchieved() {
|
2026-07-05 15:05:14 +03:00
|
|
|
let today = cal.startOfDay(for: Date())
|
2026-07-05 19:51:46 +03:00
|
|
|
var dates: [String] = []
|
2026-07-05 15:05:14 +03:00
|
|
|
guard let week = cal.dateInterval(of: .weekOfYear, for: today) else { return XCTFail() }
|
|
|
|
|
var d = week.start, added = 0
|
|
|
|
|
while d <= today {
|
|
|
|
|
let key = fmt.string(from: d)
|
2026-07-05 19:51:46 +03:00
|
|
|
if key != day(-2, from: today) { dates.append(key); added += 1 } // skip one day
|
2026-07-05 15:05:14 +03:00
|
|
|
d = cal.date(byAdding: .day, value: 1, to: d)!
|
|
|
|
|
}
|
2026-07-05 19:51:46 +03:00
|
|
|
XCTAssertEqual(WorkoutViewModel.totalAchievedWorkoutDays(dates), added,
|
|
|
|
|
"every logged day counts; the skipped day is simply absent")
|
2026-07-05 15:05:14 +03:00
|
|
|
}
|
|
|
|
|
|
2026-07-05 19:51:46 +03:00
|
|
|
/// A below-goal week (4/5) and a full week (5/5) both contribute all their
|
|
|
|
|
/// achieved days — 9 total, nothing lost to the 4/5 week "failing."
|
|
|
|
|
func testBelowGoalAndFullWeekBothCountFully() {
|
2026-07-05 15:05:14 +03:00
|
|
|
let today = cal.startOfDay(for: Date())
|
2026-07-05 19:51:46 +03:00
|
|
|
guard let prevRef = cal.date(byAdding: .weekOfYear, value: -1, to: today),
|
|
|
|
|
let prevWeek = cal.dateInterval(of: .weekOfYear, for: prevRef),
|
|
|
|
|
let thisWeek = cal.dateInterval(of: .weekOfYear, for: today)
|
2026-07-05 15:05:14 +03:00
|
|
|
else { return XCTFail() }
|
|
|
|
|
|
2026-07-05 19:51:46 +03:00
|
|
|
var dates: [String] = []
|
2026-07-05 15:05:14 +03:00
|
|
|
var d = prevWeek.start
|
2026-07-05 19:51:46 +03:00
|
|
|
for i in 0..<7 { if i < 4 { dates.append(fmt.string(from: d)) }; d = cal.date(byAdding: .day, value: 1, to: d)! } // 4/5
|
|
|
|
|
d = thisWeek.start
|
|
|
|
|
for i in 0..<7 { if i < 5 { dates.append(fmt.string(from: d)) }; d = cal.date(byAdding: .day, value: 1, to: d)! } // 5/5
|
|
|
|
|
|
|
|
|
|
XCTAssertEqual(WorkoutViewModel.totalAchievedWorkoutDays(dates), 9,
|
|
|
|
|
"4/5 + 5/5 = 9; a below-goal week is never discarded")
|
2026-07-05 15:05:14 +03:00
|
|
|
}
|
|
|
|
|
|
2026-07-05 19:51:46 +03:00
|
|
|
/// A completely blank week between two active stretches does not reset the
|
|
|
|
|
/// tally — earlier achievements are preserved.
|
|
|
|
|
func testBlankWeekDoesNotReset() {
|
2026-07-05 15:05:14 +03:00
|
|
|
let today = cal.startOfDay(for: Date())
|
2026-07-05 19:51:46 +03:00
|
|
|
guard let twoBack = cal.date(byAdding: .weekOfYear, value: -2, to: today),
|
|
|
|
|
let oldWeek = cal.dateInterval(of: .weekOfYear, for: twoBack),
|
|
|
|
|
let thisWeek = cal.dateInterval(of: .weekOfYear, for: today)
|
2026-07-05 15:05:14 +03:00
|
|
|
else { return XCTFail() }
|
|
|
|
|
|
2026-07-05 19:51:46 +03:00
|
|
|
var dates: [String] = []
|
|
|
|
|
var d = oldWeek.start
|
|
|
|
|
for i in 0..<7 { if i < 3 { dates.append(fmt.string(from: d)) }; d = cal.date(byAdding: .day, value: 1, to: d)! }
|
|
|
|
|
// (previous week: nothing — a blank week)
|
|
|
|
|
dates.append(fmt.string(from: thisWeek.start))
|
2026-07-05 15:05:14 +03:00
|
|
|
|
2026-07-05 19:51:46 +03:00
|
|
|
XCTAssertEqual(WorkoutViewModel.totalAchievedWorkoutDays(dates), 4,
|
|
|
|
|
"3 old + 1 now; the empty week in between changes nothing")
|
2026-07-05 15:05:14 +03:00
|
|
|
}
|
|
|
|
|
|
2026-07-05 19:51:46 +03:00
|
|
|
/// Duplicate day entries (e.g. HealthKit merge) never double-count.
|
|
|
|
|
func testDuplicatesCountOnce() {
|
|
|
|
|
let key = fmt.string(from: cal.startOfDay(for: Date()))
|
|
|
|
|
XCTAssertEqual(WorkoutViewModel.totalAchievedWorkoutDays([key, key, key]), 1)
|
2026-07-05 15:05:14 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func testEmptyDataIsZero() {
|
2026-07-05 19:51:46 +03:00
|
|
|
XCTAssertEqual(WorkoutViewModel.totalAchievedWorkoutDays([]), 0)
|
2026-07-05 15:05:14 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - Task completion counting (recurring occurrences)
|
|
|
|
|
|
|
|
|
|
private func makeRecurring(title: String, completedOn keys: [String]) -> TaskItem {
|
|
|
|
|
var t = TaskItem(title: title, dueDate: cal.date(byAdding: .day, value: -30, to: Date()))
|
|
|
|
|
t.isRecurring = true
|
|
|
|
|
t.recurrenceLabel = "Daily"
|
|
|
|
|
t.completedOccurrences = keys
|
|
|
|
|
return t
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Recurring completions must count toward per-day stats even though the
|
|
|
|
|
/// stored task's isComplete/completedAt stay untouched.
|
|
|
|
|
func testRecurringOccurrencesCountedPerDay() {
|
|
|
|
|
let today = cal.startOfDay(for: Date())
|
|
|
|
|
let todayKey = fmt.string(from: today)
|
|
|
|
|
let recurring = makeRecurring(title: "Morning routine", completedOn: [todayKey])
|
|
|
|
|
|
|
|
|
|
var oneOff = TaskItem(title: "Buy milk")
|
|
|
|
|
oneOff.isComplete = true
|
|
|
|
|
oneOff.completedAt = today.addingTimeInterval(3600)
|
|
|
|
|
|
|
|
|
|
let n = TaskViewModel.completionCount(in: [recurring, oneOff], on: today, calendar: cal)
|
|
|
|
|
XCTAssertEqual(n, 2, "one recurring occurrence + one one-off completion")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func testRecurringNotCountedOnOtherDays() {
|
|
|
|
|
let today = cal.startOfDay(for: Date())
|
|
|
|
|
let yesterdayKey = day(-1, from: today)
|
|
|
|
|
let recurring = makeRecurring(title: "Morning routine", completedOn: [yesterdayKey])
|
|
|
|
|
|
|
|
|
|
XCTAssertEqual(TaskViewModel.completionCount(in: [recurring], on: today, calendar: cal), 0)
|
|
|
|
|
let yesterday = cal.date(byAdding: .day, value: -1, to: today)!
|
|
|
|
|
XCTAssertEqual(TaskViewModel.completionCount(in: [recurring], on: yesterday, calendar: cal), 1)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func testIncompleteOneOffNotCounted() {
|
|
|
|
|
let today = cal.startOfDay(for: Date())
|
|
|
|
|
let t = TaskItem(title: "Open task")
|
|
|
|
|
XCTAssertEqual(TaskViewModel.completionCount(in: [t], on: today, calendar: cal), 0)
|
|
|
|
|
}
|
|
|
|
|
}
|