153 lines
6.6 KiB
Swift
153 lines
6.6 KiB
Swift
|
|
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<String>()
|
||
|
|
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<String>()
|
||
|
|
// 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<String>()
|
||
|
|
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<String>()
|
||
|
|
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)
|
||
|
|
}
|
||
|
|
}
|