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)!) } // MARK: - Workout weekly-goal streak /// A skipped day within the weekly goal must NOT reset the streak. /// Scenario from the field: 6-day schedule, goal 5, Thursday skipped, /// 5 workouts done that week → streak counts all 5, not 1. func testSkippedDayWithinGoalKeepsStreak() { let today = cal.startOfDay(for: Date()) // Build "this week": every day up to today except one gap two days ago. var dates = Set() 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) if key != day(-2, from: today) { dates.insert(key); added += 1 } d = cal.date(byAdding: .day, value: 1, to: d)! } guard added >= 2 else { return } // too early in the week to be meaningful let streak = WorkoutViewModel.weeklyGoalStreak( workoutDates: dates, scheduledDayCount: 6, goal: 5, today: today, calendar: cal) XCTAssertEqual(streak, added, "days this week minus the gap should all count") XCTAssertGreaterThan(streak, 1, "a mid-week skip must not collapse the streak to 1") } /// Rest days (no workout, none scheduled) never break the chain: /// a full previous week meeting the goal chains into this week. func testRestDaysDoNotBreakChainAcrossWeeks() { let today = cal.startOfDay(for: Date()) guard let thisWeek = cal.dateInterval(of: .weekOfYear, for: today), let prevRef = cal.date(byAdding: .weekOfYear, value: -1, to: today), let prevWeek = cal.dateInterval(of: .weekOfYear, for: prevRef) else { return XCTFail() } var dates = Set() // Previous week: 5 workout days (goal met), 2 rest days. var d = prevWeek.start for i in 0..<7 { if i < 5 { dates.insert(fmt.string(from: d)) } d = cal.date(byAdding: .day, value: 1, to: d)! } // This week: workout on the first day only. dates.insert(fmt.string(from: thisWeek.start)) guard thisWeek.start <= today else { return XCTFail() } let streak = WorkoutViewModel.weeklyGoalStreak( workoutDates: dates, scheduledDayCount: 5, goal: 5, today: today, calendar: cal) XCTAssertEqual(streak, 6, "5 from the kept previous week + 1 this week") } /// A previous week below the goal ends the chain — current week still counts. func testFailedWeekBreaksChain() { let today = cal.startOfDay(for: Date()) guard let thisWeek = cal.dateInterval(of: .weekOfYear, for: today), let prevRef = cal.date(byAdding: .weekOfYear, value: -1, to: today), let prevWeek = cal.dateInterval(of: .weekOfYear, for: prevRef) else { return XCTFail() } var dates = Set() dates.insert(fmt.string(from: prevWeek.start)) // only 1 day done last week dates.insert(fmt.string(from: thisWeek.start)) // 1 day this week let streak = WorkoutViewModel.weeklyGoalStreak( workoutDates: dates, scheduledDayCount: 5, goal: 5, today: today, calendar: cal) XCTAssertEqual(streak, 1, "chain stops at the failed week; this week still counts") } /// Goal is capped by the schedule: a 3-day program with goal 5 keeps the /// chain at 3 workouts/week. func testGoalCappedByScheduledDays() { let today = cal.startOfDay(for: Date()) guard let prevRef = cal.date(byAdding: .weekOfYear, value: -1, to: today), let prevWeek = cal.dateInterval(of: .weekOfYear, for: prevRef) else { return XCTFail() } var dates = Set() var d = prevWeek.start for i in 0..<7 { if i < 3 { dates.insert(fmt.string(from: d)) } // 3 of 3 scheduled d = cal.date(byAdding: .day, value: 1, to: d)! } let streak = WorkoutViewModel.weeklyGoalStreak( workoutDates: dates, scheduledDayCount: 3, goal: 5, today: today, calendar: cal) XCTAssertEqual(streak, 3, "3-day schedule meets its capped goal and chains") } func testEmptyDataIsZero() { let streak = WorkoutViewModel.weeklyGoalStreak( workoutDates: [], scheduledDayCount: 6, goal: 5, today: Date(), calendar: cal) XCTAssertEqual(streak, 0) } // 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) } }