The UX redesign's first landing (spec in UX-REDESIGN.md): ContentView becomes a Today / Progress / Settings TabView, "Routine" replaces "Split" in every user-facing string and view name (code-level types keep their names), and workout starting moves to shared WorkoutStarter / StartedWorkoutNavigator plumbing. - New Progress tab: weekly goal streaks, workout trends, per-exercise weight progression, achievements, and the full history list (WorkoutLogsView -> WorkoutHistoryView). - Goals: stable categories workouts roll up to, managed from Settings. - New Meditation exercise + starter routine; timed sits record to Apple Health as Mind & Body sessions. Claude-Session: https://claude.ai/code/session_012qw2itfzKyEJ1HpsFt8Ex4
520 lines
26 KiB
Swift
520 lines
26 KiB
Swift
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<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: 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<Date> = [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<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
|
||
// (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<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 currentWeekBrokenFrequencyDaysRemainingVsRequirement() {
|
||
let twicePerWeek = schedule(recurrence: .frequency, timesPerWeek: 2, createdAt: day("2026-06-01"))
|
||
let weekStart = day("2026-07-06")
|
||
let oneDone: Set<Date> = [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)
|
||
}
|
||
}
|