Files
workouts/WorkoutsTests/ProgressPlannerTests.swift
T
rzen 7cb2d6da26 Show only the day's docket on the Today board and add ad hoc Now workouts
- Due-filter schedules: daily always, fixed days by weekday, one-offs on
  their date; rest days get an empty state
- Workouts with no due schedule row (ad hoc or off-day starts) now render
  as their own board rows
- The + sheet is now "New Workout" with a "When" picker; "Now" (offered
  when adding on today) skips the schedule and starts the workout directly
- Remove the times-per-week scheduling mode everywhere (enum, document,
  entity, mappers, planner, seeds, tests)
2026-07-11 11:09:11 -04:00

469 lines
24 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 MondaySunday 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,
onceDate: Date? = nil,
createdAt: Date,
routineID: String = "R-1",
routineName: String = "Push Day"
) -> ScheduleFacts {
ScheduleFacts(id: id, goal: goal, recurrence: recurrence, weekdays: weekdays,
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<Date> = [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<Date> = [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<Date> = [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: 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<Date> = [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
// (WedSun = 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 (WedSun, 5 days) can't
// reach the full 7 — the week is already unmeetable.
let missedYesterday: Set<Date> = [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<Date> = [day("2026-07-06"), day("2026-07-07")]
#expect(!ProgressPlanner.currentWeekBroken(schedule: daily, doneDays: undoneToday,
weekStart: weekStart, calendar: cal, now: now))
}
@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)
}
}