import Foundation import Testing @testable import Workouts /// Locks the derivation rules of `ProgressPlanner` — the Progress tab's pure adherence, /// streak, trend, and achievement engine (precedent: `SeedReconcilePlanner`). Every /// assertion below is a deterministic function of plain value snapshots (`ScheduleFacts`, /// `WorkoutFacts`, bare `WeekMark`/`GoalTrack`) run through a fixed UTC, Monday-first /// calendar, so nothing here depends on the host machine's clock, locale, or timezone. struct ProgressPlannerTests { // MARK: - Fixed calendar & date helpers /// UTC Gregorian, Monday-first weeks — matches the app's real `weekOfYear` grid /// without depending on the host machine's locale or timezone. private let cal: Calendar = { var cal = Calendar(identifier: .gregorian) cal.timeZone = TimeZone(identifier: "UTC")! cal.firstWeekday = 2 return cal }() /// Midnight UTC on an ISO `yyyy-MM-dd` date. All the fixture dates below live in /// June/July 2026, whose weekday layout (verified against two independent anchors) /// is: 2026-06-29 is a Monday, so the two relevant Monday–Sunday weeks are /// 06-29…07-05 and 07-06…07-12. private func day(_ iso: String) -> Date { let parts = iso.split(separator: "-").compactMap { Int($0) } var components = DateComponents() components.year = parts[0]; components.month = parts[1]; components.day = parts[2] return cal.date(from: components)! } /// The same calendar day at a specific hour, for time-of-day achievements. private func at(_ base: Date, hour: Int) -> Date { cal.date(bySettingHour: hour, minute: 0, second: 0, of: base)! } // MARK: - Fixtures private func schedule( id: String = "SCH-1", goal: GoalKind? = .strength, recurrence: ScheduleRecurrence, weekdays: [Int]? = nil, timesPerWeek: Int? = nil, onceDate: Date? = nil, createdAt: Date, routineID: String = "R-1", routineName: String = "Push Day" ) -> ScheduleFacts { ScheduleFacts(id: id, goal: goal, recurrence: recurrence, weekdays: weekdays, timesPerWeek: timesPerWeek, onceDate: onceDate, createdAt: createdAt, routineID: routineID, routineName: routineName) } private func workout( routineID: String? = "R-1", start: Date, end: Date? = nil, completed: Bool = true, volume: Double = 0, activeMinutes: Double = 0, energyKcal: Double = 0 ) -> WorkoutFacts { WorkoutFacts(routineID: routineID, start: start, end: end, completed: completed, volume: volume, activeMinutes: activeMinutes, energyKcal: energyKcal) } /// A bare `WeekMark` for exercising `streak`/`bestHitRun` directly — their logic /// only reads `met`/`expected`, never `weekStart`, so a fixed date is fine. private func mark(_ met: Int, _ expected: Int) -> WeekMark { WeekMark(weekStart: day("2026-01-01"), met: met, expected: expected) } /// A bare `GoalTrack` for exercising `cleanSweepExists` (reached via `achievements`) /// directly, without routing through `goalTracks`. private func goalTrack(_ goal: GoalKind?, _ weeks: [WeekMark]) -> GoalTrack { GoalTrack(goal: goal, scheduleTracks: [], weeks: weeks, streakWeeks: 0, streakAlive: true) } // MARK: - weekMark: daily @Test func weekMarkDaily() { let daily = schedule(recurrence: .daily, createdAt: day("2026-06-01")) let now = day("2026-07-08") // Wednesday; current week is Mon 07-06 – Sun 07-12 // A fully-past week (Mon 06-29 – Sun 07-05), every day done. let pastWeek = day("2026-06-29") let fullyMetDays = Set((0..<7).map { cal.date(byAdding: .day, value: $0, to: pastWeek)! }) let fullyMet = ProgressPlanner.weekMark(schedule: daily, doneDays: fullyMetDays, weekStart: pastWeek, calendar: cal, now: now) #expect(fullyMet?.met == 7) #expect(fullyMet?.expected == 7) #expect(fullyMet?.hit == true) // Same past week, only 4 of 7 days done — a clean met/expected count, not hit. let partialDays = Set([0, 1, 3, 5].map { cal.date(byAdding: .day, value: $0, to: pastWeek)! }) let partial = ProgressPlanner.weekMark(schedule: daily, doneDays: partialDays, weekStart: pastWeek, calendar: cal, now: now) #expect(partial?.met == 4) #expect(partial?.expected == 7) #expect(partial?.hit == false) // The current week (Mon 07-06 – Sun 07-12): only Mon/Tue/Wed have elapsed by // `now`, so expected clamps to 3, not the full 7 — and a "done" day beyond // today wouldn't count even if present in doneDays. let currentWeek = day("2026-07-06") let doneSoFar: Set = [day("2026-07-06"), day("2026-07-07")] let current = ProgressPlanner.weekMark(schedule: daily, doneDays: doneSoFar, weekStart: currentWeek, calendar: cal, now: now) #expect(current?.expected == 3) #expect(current?.met == 2) } // MARK: - weekMark: fixedDays @Test func weekMarkFixedDays() { // Required on Mon/Wed/Fri (weekday numbers 2, 4, 6). let fixed = schedule(recurrence: .fixedDays, weekdays: [2, 4, 6], createdAt: day("2026-06-01")) let now = day("2026-07-08") // Wednesday // Met exactly on the fixed days themselves. let pastWeek = day("2026-06-29") let onFixedDays: Set = [day("2026-06-29"), day("2026-07-01"), day("2026-07-03")] let metOnFixed = ProgressPlanner.weekMark(schedule: fixed, doneDays: onFixedDays, weekStart: pastWeek, calendar: cal, now: now) #expect(metOnFixed?.met == 3) #expect(metOnFixed?.expected == 3) #expect(metOnFixed?.hit == true) // A catch-up on a non-fixed day (Tuesday) still counts toward met: Monday // done + a Tuesday catch-up reaches met == 2 even though Wednesday and Friday // were both skipped — the requirement is 3, so it still isn't hit, but the // catch-up visibly moved met from what a fixed-days-only count (1) would give. let withCatchUp: Set = [day("2026-06-29"), day("2026-06-30")] let catchUp = ProgressPlanner.weekMark(schedule: fixed, doneDays: withCatchUp, weekStart: pastWeek, calendar: cal, now: now) #expect(catchUp?.met == 2) #expect(catchUp?.expected == 3) #expect(catchUp?.hit == false) // The current week (Mon 07-06 – Sun 07-12): expected counts only the fixed // days that have already elapsed (Mon + Wed), not the not-yet-arrived Friday. let currentWeek = day("2026-07-06") let current = ProgressPlanner.weekMark(schedule: fixed, doneDays: [day("2026-07-06"), day("2026-07-08")], weekStart: currentWeek, calendar: cal, now: now) #expect(current?.expected == 2) #expect(current?.met == 2) } // MARK: - weekMark: frequency @Test func weekMarkFrequency() { let now = day("2026-07-08") // Wednesday; current week Mon 07-06 – Sun 07-12 let currentWeek = day("2026-07-06") // Expected is the full weekly target even mid-week — NOT clamped down to the // 3 days that have elapsed so far. let twicePerWeek = schedule(recurrence: .frequency, timesPerWeek: 2, createdAt: day("2026-06-01")) let untouched = ProgressPlanner.weekMark(schedule: twicePerWeek, doneDays: [], weekStart: currentWeek, calendar: cal, now: now) #expect(untouched?.expected == 2) #expect(untouched?.met == 0) // Met is capped at the target even when more sessions were logged. let threeDone: Set = [day("2026-07-06"), day("2026-07-07"), day("2026-07-08")] let overMet = ProgressPlanner.weekMark(schedule: twicePerWeek, doneDays: threeDone, weekStart: currentWeek, calendar: cal, now: now) #expect(overMet?.met == 2) #expect(overMet?.expected == 2) #expect(overMet?.hit == true) // Target clamps to the days the schedule existed in its creation week: created // Wednesday leaves only 5 days (Wed–Sun) in that week, so a 7x/week target // clamps down to 5, not 7. let createdMidWeek = schedule(recurrence: .frequency, timesPerWeek: 7, createdAt: day("2026-07-08")) let clamped = ProgressPlanner.weekMark(schedule: createdMidWeek, doneDays: [], weekStart: currentWeek, calendar: cal, now: day("2026-07-12")) #expect(clamped?.expected == 5) } // MARK: - weekMark: once @Test func weekMarkOnce() { let onceDate = day("2026-07-08") // Wednesday, in week Mon 07-06 – Sun 07-12 let once = schedule(recurrence: .once, onceDate: onceDate, createdAt: day("2026-06-01")) let now = day("2026-07-10") // A week that doesn't contain the once date: nil, whether before or after it. #expect(ProgressPlanner.weekMark(schedule: once, doneDays: [], weekStart: day("2026-06-29"), calendar: cal, now: now) == nil) #expect(ProgressPlanner.weekMark(schedule: once, doneDays: [], weekStart: day("2026-07-13"), calendar: cal, now: now) == nil) // Its own week: expected is always 1. let ownWeek = day("2026-07-06") let unmet = ProgressPlanner.weekMark(schedule: once, doneDays: [], weekStart: ownWeek, calendar: cal, now: now) #expect(unmet?.expected == 1) #expect(unmet?.met == 0) // Met when completed on ANY day that week — not necessarily the once date itself. let anyDayDone: Set = [day("2026-07-07")] let met = ProgressPlanner.weekMark(schedule: once, doneDays: anyDayDone, weekStart: ownWeek, calendar: cal, now: now) #expect(met?.met == 1) #expect(met?.hit == true) } // MARK: - weekMark: creation-window edges @Test func weekMarkNilBeforeCreationAndClipsCreationWeek() { let createdAt = day("2026-07-08") // Wednesday let daily = schedule(recurrence: .daily, createdAt: createdAt) let now = day("2026-07-12") // A week entirely before creation: nil, not a zeroed mark. #expect(ProgressPlanner.weekMark(schedule: daily, doneDays: [], weekStart: day("2026-06-29"), calendar: cal, now: now) == nil) // The creation week itself: expected only counts the days since creation // (Wed–Sun = 5), not the full 7-day week. let creationWeek = day("2026-07-06") let mark = ProgressPlanner.weekMark(schedule: daily, doneDays: [], weekStart: creationWeek, calendar: cal, now: now) #expect(mark?.expected == 5) } // MARK: - currentWeekBroken @Test func currentWeekBrokenDailyMissedYesterdayVsUndoneToday() { let daily = schedule(recurrence: .daily, createdAt: day("2026-06-01")) let now = day("2026-07-08") // Wednesday; current week Mon 07-06 – Sun 07-12 let weekStart = day("2026-07-06") // Missed Tuesday: even finishing every remaining day (Wed–Sun, 5 days) can't // reach the full 7 — the week is already unmeetable. let missedYesterday: Set = [day("2026-07-06")] #expect(ProgressPlanner.currentWeekBroken(schedule: daily, doneDays: missedYesterday, weekStart: weekStart, calendar: cal, now: now)) // Monday and Tuesday both done; today (Wednesday) simply hasn't happened yet — // still fully recoverable, not broken. let undoneToday: Set = [day("2026-07-06"), day("2026-07-07")] #expect(!ProgressPlanner.currentWeekBroken(schedule: daily, doneDays: undoneToday, weekStart: weekStart, calendar: cal, now: now)) } @Test func currentWeekBrokenFrequencyDaysRemainingVsRequirement() { let twicePerWeek = schedule(recurrence: .frequency, timesPerWeek: 2, createdAt: day("2026-06-01")) let weekStart = day("2026-07-06") let oneDone: Set = [day("2026-07-07")] // Saturday: 2 days left (Sat, Sun). 1 done, 1 still needed — achievable. #expect(!ProgressPlanner.currentWeekBroken(schedule: twicePerWeek, doneDays: oneDone, weekStart: weekStart, calendar: cal, now: day("2026-07-11"))) // Sunday: only 1 day left (today itself). 1 done, 1 still needed — needs // exactly 1 ≤ 1 day left, so still achievable. #expect(!ProgressPlanner.currentWeekBroken(schedule: twicePerWeek, doneDays: oneDone, weekStart: weekStart, calendar: cal, now: day("2026-07-12"))) // Sunday again, but nothing done yet: 2 still needed with only 1 day left — broken. #expect(ProgressPlanner.currentWeekBroken(schedule: twicePerWeek, doneDays: [], weekStart: weekStart, calendar: cal, now: day("2026-07-12"))) } @Test func currentWeekBrokenFixedDaysCatchUpDaysRemainingNotBroken() { // Mon/Wed/Fri required. Monday (a fixed day) was missed, but it's only // Tuesday — catch-up days (Wed, Fri, or any other day this week) still remain. let fixed = schedule(recurrence: .fixedDays, weekdays: [2, 4, 6], createdAt: day("2026-06-01")) #expect(!ProgressPlanner.currentWeekBroken(schedule: fixed, doneDays: [], weekStart: day("2026-07-06"), calendar: cal, now: day("2026-07-07"))) } // MARK: - streak @Test func streakCountsConsecutiveHitWeeksNewestToOldest() { // Three consecutive hits ending at the newest (current) week. #expect(ProgressPlanner.streak(weeks: [mark(1, 1), mark(1, 1), mark(1, 1)], alive: true) == 3) // A miss two weeks back stops the run at the newest unbroken stretch. #expect(ProgressPlanner.streak(weeks: [mark(0, 1), mark(1, 1), mark(1, 1)], alive: true) == 2) } @Test func streakCurrentWeekUnhitButAliveNeitherCountsNorBreaks() { // The current (last) week isn't hit yet but is still meetable ("alive") — it // neither adds to the count nor zeroes it; the streak reads from the prior // unbroken run. let weeks = [mark(1, 1), mark(1, 1), mark(0, 1)] #expect(ProgressPlanner.streak(weeks: weeks, alive: true) == 2) } @Test func streakCurrentWeekUnhitAndDeadReturnsZero() { // Same shape, but the current week is already unmeetable ("dead") — the whole // streak breaks, even though the two prior weeks were hits. let weeks = [mark(1, 1), mark(1, 1), mark(0, 1)] #expect(ProgressPlanner.streak(weeks: weeks, alive: false) == 0) } @Test func streakVacuousWeeksAreTransparent() { // A vacuous (expected 0) week between two hits neither breaks the run nor // resets it — it's invisible to the scan, so the hits on either side bridge // across it into one streak. let weeks = [mark(1, 1), mark(0, 0), mark(1, 1)] #expect(ProgressPlanner.streak(weeks: weeks, alive: true) == 2) // Alive is irrelevant when the current week is itself vacuous. let vacuousCurrent = [mark(1, 1), mark(1, 1), mark(0, 0)] #expect(ProgressPlanner.streak(weeks: vacuousCurrent, alive: false) == 2) } // MARK: - bestHitRun & longestDayRun @Test func bestHitRunFindsLongestRunAroundAMissWithVacuousWeeksTransparent() { // hit, miss, hit, hit, vacuous, hit, hit — the miss resets the run, but the // vacuous week doesn't, so the trailing run bridges to length 4. let weeks = [mark(1, 1), mark(0, 1), mark(1, 1), mark(1, 1), mark(0, 0), mark(1, 1), mark(1, 1)] #expect(ProgressPlanner.bestHitRun(weeks: weeks) == 4) } @Test func longestDayRunCountsConsecutiveDaysAcrossAGap() { let workouts = [ workout(start: day("2026-07-01")), workout(start: day("2026-07-02")), workout(start: day("2026-07-03")), workout(start: day("2026-07-04"), completed: false), // doesn't bridge the gap workout(start: day("2026-07-06")), workout(start: day("2026-07-07")), ] #expect(ProgressPlanner.longestDayRun(workouts: workouts, calendar: cal) == 3) } // MARK: - goalTracks @Test func goalTracksGroupsByGoalOrderWithTrailingNilGroupAndSkipsEmptyGoals() { let now = day("2026-07-08") let strength1 = schedule(id: "S1", goal: .strength, recurrence: .daily, createdAt: day("2026-06-01")) let strength2 = schedule(id: "S2", goal: .strength, recurrence: .daily, createdAt: day("2026-06-01")) let unassigned = schedule(id: "S3", goal: nil, recurrence: .daily, createdAt: day("2026-06-01")) let tracks = ProgressPlanner.goalTracks( schedules: [strength1, strength2, unassigned], workouts: [], weeksBack: 1, goalOrder: [.strength, .mobility, .cardio, .mindfulness], calendar: cal, now: now) // Only goals with member schedules produce a track (mobility/cardio/mindfulness // are skipped entirely, not emitted empty); the goal-less group trails last. #expect(tracks.map(\.goal) == [.strength, nil]) #expect(tracks[0].scheduleTracks.map(\.schedule.id) == ["S1", "S2"]) } @Test func goalTracksRollupSumsAndStreakAliveFollowsBrokenMember() throws { let now = day("2026-07-08") // Wednesday; current week Mon 07-06 – Sun 07-12 let onTrack = schedule(id: "S1", goal: .strength, recurrence: .daily, createdAt: day("2026-06-01"), routineID: "R-ok") let broken = schedule(id: "S2", goal: .strength, recurrence: .daily, createdAt: day("2026-06-01"), routineID: "R-broken") let onTrackWorkouts = [day("2026-07-06"), day("2026-07-07"), day("2026-07-08")] .map { workout(routineID: "R-ok", start: $0) } // Missed Tuesday permanently — even finishing every remaining day can't reach 7/7. let brokenWorkouts = [workout(routineID: "R-broken", start: day("2026-07-06"))] let tracks = ProgressPlanner.goalTracks( schedules: [onTrack, broken], workouts: onTrackWorkouts + brokenWorkouts, weeksBack: 1, calendar: cal, now: now) let strength = try #require(tracks.first { $0.goal == .strength }) // Rollup sums met/expected across BOTH member schedules for the same week. #expect(strength.weeks.last?.met == 4) // 3 (onTrack) + 1 (broken) #expect(strength.weeks.last?.expected == 6) // 3 (onTrack) + 3 (broken, elapsed so far) // One member's current week is already unmeetable, so the goal's streak is dead. #expect(strength.streakAlive == false) } // MARK: - trendPoints @Test func trendPointsFiltersCompletedOnlyAndZeroFillsDailyBucketsForWeek() { let now = day("2026-07-08") // cutoff = now - 7 days = 07-01 let workouts = [ workout(start: day("2026-07-02"), completed: true, volume: 10), workout(start: day("2026-07-02"), completed: true, volume: 5), // same-day sum workout(start: day("2026-07-03"), completed: false, volume: 999), // excluded entirely workout(start: day("2026-07-05"), completed: true, volume: 20), ] let points = ProgressPlanner.trendPoints(workouts: workouts, range: .week, calendar: cal, now: now) // One point per day from the cutoff (07-01) through now (07-08) inclusive. #expect(points.count == 8) #expect(points.first?.date == day("2026-07-01")) #expect(points.last?.date == day("2026-07-08")) func point(_ iso: String) -> TrendPoint? { points.first { $0.date == day(iso) } } #expect(point("2026-07-02")?.workouts == 2) #expect(point("2026-07-02")?.volume == 15) // The gap day had an incomplete workout, but it's filtered out — zero-filled, // not dropped and not counted. #expect(point("2026-07-03")?.workouts == 0) #expect(point("2026-07-04")?.workouts == 0) #expect(point("2026-07-05")?.workouts == 1) #expect(point("2026-07-05")?.volume == 20) } @Test func trendPointsZeroFillsEntireWindowEvenWithNoWorkouts() { // Bounded ranges (a non-nil cutoff) zero-fill the whole window regardless of // data — only the unbounded `.all` range can produce an empty array. let points = ProgressPlanner.trendPoints(workouts: [], range: .week, calendar: cal, now: day("2026-07-08")) #expect(points.count == 8) #expect(points.allSatisfy { $0.workouts == 0 }) } @Test func trendPointsWeeklyBucketsForQuarter() { let now = day("2026-07-08") // Wednesday, week of Mon 07-06 let workouts = [ workout(start: day("2026-07-06")), // same week as 07-08 workout(start: day("2026-07-08")), workout(start: day("2026-06-22")), // an earlier week ] let points = ProgressPlanner.trendPoints(workouts: workouts, range: .quarter, calendar: cal, now: now) let thisWeek = points.first { $0.date == day("2026-07-06") } #expect(thisWeek?.workouts == 2) let earlierWeekStart = cal.dateInterval(of: .weekOfYear, for: day("2026-06-22"))!.start let earlierWeek = points.first { $0.date == earlierWeekStart } #expect(earlierWeek?.workouts == 1) } @Test func trendPointsMonthlyBucketsForYear() { let now = day("2026-07-08") let workouts = [ workout(start: day("2026-07-02")), workout(start: day("2026-07-20")), workout(start: day("2026-06-15")), ] let points = ProgressPlanner.trendPoints(workouts: workouts, range: .year, calendar: cal, now: now) let july = points.first { $0.date == cal.dateInterval(of: .month, for: day("2026-07-02"))!.start } #expect(july?.workouts == 2) let june = points.first { $0.date == cal.dateInterval(of: .month, for: day("2026-06-15"))!.start } #expect(june?.workouts == 1) } @Test func trendPointsAllRangeStartsAtEarliestWorkout() { let now = day("2026-07-08") let workouts = [ workout(start: day("2026-05-01")), workout(start: day("2026-07-08")), ] let points = ProgressPlanner.trendPoints(workouts: workouts, range: .all, calendar: cal, now: now) #expect(points.first?.date == cal.dateInterval(of: .month, for: day("2026-05-01"))!.start) #expect(points.last?.date == cal.dateInterval(of: .month, for: now)!.start) #expect(points.count == 3) // May, June, July } @Test func trendPointsEmptyInputReturnsEmptyArrayForAllRange() { let points = ProgressPlanner.trendPoints(workouts: [], range: .all, calendar: cal, now: day("2026-07-08")) #expect(points.isEmpty) } // MARK: - achievements @Test func achievementsCountMilestoneProgressReflectsCompletedCount() throws { let completedWorkouts = (1...5).map { workout(start: day("2026-07-0\($0)")) } let badges = ProgressPlanner.achievements(tracks: [], workouts: completedWorkouts, calendar: cal, now: day("2026-07-08")) // 5 completed, target 10 — not earned, halfway there. let tenClub = try #require(badges.first { $0.id == "count-10" }) #expect(tenClub.earned == false) #expect(tenClub.progress == 0.5) // 5 completed clears the 1-session milestone outright. let firstRep = try #require(badges.first { $0.id == "count-1" }) #expect(firstRep.earned == true) #expect(firstRep.progress == 1) } @Test func achievementsEarlyBirdAndNightOwlFromStartHours() throws { let now = day("2026-07-08") let earlyBadges = ProgressPlanner.achievements( tracks: [], workouts: [workout(start: at(day("2026-07-01"), hour: 6))], calendar: cal, now: now) #expect(try #require(earlyBadges.first { $0.id == "early-bird" }).earned == true) #expect(try #require(earlyBadges.first { $0.id == "night-owl" }).earned == false) let lateBadges = ProgressPlanner.achievements( tracks: [], workouts: [workout(start: at(day("2026-07-01"), hour: 22))], calendar: cal, now: now) #expect(try #require(lateBadges.first { $0.id == "night-owl" }).earned == true) #expect(try #require(lateBadges.first { $0.id == "early-bird" }).earned == false) // Boundary hours: exactly 7 AM does not count as early (< 7 required); exactly // 9 PM does count as late (>= 21 required). let boundaryBadges = ProgressPlanner.achievements( tracks: [], workouts: [workout(start: at(day("2026-07-01"), hour: 7)), workout(start: at(day("2026-07-01"), hour: 21))], calendar: cal, now: now) #expect(try #require(boundaryBadges.first { $0.id == "early-bird" }).earned == false) #expect(try #require(boundaryBadges.first { $0.id == "night-owl" }).earned == true) } @Test func achievementsCleanSweepRequiresTwoNonVacuousGoalsHitInTheSameWeek() throws { let now = day("2026-07-08") // Only one goal is non-vacuously hit that week (the other is vacuous, i.e. // imposed nothing) — not a sweep, even though every present mark is "hit". let noSweep = [goalTrack(.strength, [mark(1, 1)]), goalTrack(.cardio, [mark(0, 0)])] let noSweepBadges = ProgressPlanner.achievements(tracks: noSweep, workouts: [], calendar: cal, now: now) #expect(try #require(noSweepBadges.first { $0.id == "clean-sweep" }).earned == false) // Two goals both non-vacuously hit the same week — a genuine sweep. let sweep = [goalTrack(.strength, [mark(1, 1)]), goalTrack(.cardio, [mark(2, 2)])] let sweepBadges = ProgressPlanner.achievements(tracks: sweep, workouts: [], calendar: cal, now: now) #expect(try #require(sweepBadges.first { $0.id == "clean-sweep" }).earned == true) } }