Restructure into a three-tab app with Progress, goals, and Meditation
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
This commit is contained in:
@@ -3,7 +3,7 @@ import Testing
|
||||
@testable import Workouts
|
||||
|
||||
/// Locks the survivor rules of `DuplicateCleanupPlanner` — the safety-critical
|
||||
/// part of the developer duplicate-cleanup tool. Referenced-split and seed
|
||||
/// part of the developer duplicate-cleanup tool. Referenced-routine and seed
|
||||
/// protection must always beat "earliest ULID wins," an in-progress workout must
|
||||
/// never be touched, and the HealthKit-link holder must always survive (deleting
|
||||
/// it would cascade-delete the real Health sample). Fixed 26-char ULID-like ids
|
||||
@@ -26,12 +26,12 @@ struct DuplicateCleanupPlannerTests {
|
||||
)
|
||||
}
|
||||
|
||||
private func split(
|
||||
id splitID: String, name: String = "Push Day", exercises: [ExerciseDocument]? = nil,
|
||||
private func routine(
|
||||
id routineID: String, name: String = "Push Day", exercises: [ExerciseDocument]? = nil,
|
||||
createdAt: Date = Date(timeIntervalSince1970: 0)
|
||||
) -> SplitDocument {
|
||||
SplitDocument(
|
||||
schemaVersion: SplitDocument.currentSchemaVersion, id: splitID, name: name, color: "indigo",
|
||||
) -> RoutineDocument {
|
||||
RoutineDocument(
|
||||
schemaVersion: RoutineDocument.currentSchemaVersion, id: routineID, name: name, color: "indigo",
|
||||
systemImage: "dumbbell.fill", order: 0, createdAt: createdAt, updatedAt: createdAt,
|
||||
exercises: exercises ?? [exercise()], activityType: nil
|
||||
)
|
||||
@@ -46,13 +46,13 @@ struct DuplicateCleanupPlannerTests {
|
||||
}
|
||||
|
||||
private func workout(
|
||||
id workoutID: String, splitID: String? = "SPLIT-1", splitName: String? = "Push Day",
|
||||
id workoutID: String, routineID: String? = "RT-1", routineName: String? = "Push Day",
|
||||
start: Date, status: String = WorkoutStatus.completed.rawValue,
|
||||
logs: [WorkoutLogDocument]? = nil, metrics: WorkoutMetrics? = nil
|
||||
) -> WorkoutDocument {
|
||||
WorkoutDocument(
|
||||
schemaVersion: WorkoutDocument.currentSchemaVersion, id: workoutID, splitID: splitID,
|
||||
splitName: splitName, start: start, end: nil, status: status, createdAt: start, updatedAt: start,
|
||||
schemaVersion: WorkoutDocument.currentSchemaVersion, id: workoutID, routineID: routineID,
|
||||
routineName: routineName, start: start, end: nil, status: status, createdAt: start, updatedAt: start,
|
||||
logs: logs ?? [log(date: start)], metrics: metrics
|
||||
)
|
||||
}
|
||||
@@ -77,56 +77,56 @@ struct DuplicateCleanupPlannerTests {
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Split survivor rules
|
||||
// MARK: - Routine survivor rules
|
||||
|
||||
@Test func identicalUnreferencedSplitsKeepEarliestULID() {
|
||||
let earlier = split(id: id(1))
|
||||
let later = split(id: id(2))
|
||||
@Test func identicalUnreferencedRoutinesKeepEarliestULID() {
|
||||
let earlier = routine(id: id(1))
|
||||
let later = routine(id: id(2))
|
||||
let plan = DuplicateCleanupPlanner.plan(
|
||||
splits: [later, earlier], workouts: [], referencedSplitIDs: [], isSeed: { _ in false }
|
||||
routines: [later, earlier], workouts: [], referencedRoutineIDs: [], isSeed: { _ in false }
|
||||
)
|
||||
#expect(plan.splitGroups.count == 1)
|
||||
#expect(plan.splitGroups[0].keep.map(\.id) == [id(1)])
|
||||
#expect(plan.splitGroups[0].delete.map(\.id) == [id(2)])
|
||||
#expect(plan.routineGroups.count == 1)
|
||||
#expect(plan.routineGroups[0].keep.map(\.id) == [id(1)])
|
||||
#expect(plan.routineGroups[0].delete.map(\.id) == [id(2)])
|
||||
}
|
||||
|
||||
/// Referenced-protection must override "earliest ULID wins": a later,
|
||||
/// referenced split survives and the earlier, unreferenced duplicate is the
|
||||
/// referenced routine survives and the earlier, unreferenced duplicate is the
|
||||
/// one deleted — the opposite of the no-protection tiebreak.
|
||||
@Test func referencedProtectionOverridesEarliestWins() {
|
||||
let earlier = split(id: id(1)) // unreferenced, would win the earliest-id tiebreak alone
|
||||
let later = split(id: id(2)) // referenced by a workout
|
||||
let earlier = routine(id: id(1)) // unreferenced, would win the earliest-id tiebreak alone
|
||||
let later = routine(id: id(2)) // referenced by a workout
|
||||
let plan = DuplicateCleanupPlanner.plan(
|
||||
splits: [earlier, later], workouts: [], referencedSplitIDs: [id(2)], isSeed: { _ in false }
|
||||
routines: [earlier, later], workouts: [], referencedRoutineIDs: [id(2)], isSeed: { _ in false }
|
||||
)
|
||||
#expect(plan.splitGroups.count == 1)
|
||||
#expect(plan.splitGroups[0].keep.map(\.id) == [id(2)])
|
||||
#expect(plan.splitGroups[0].delete.map(\.id) == [id(1)])
|
||||
#expect(plan.routineGroups.count == 1)
|
||||
#expect(plan.routineGroups[0].keep.map(\.id) == [id(2)])
|
||||
#expect(plan.routineGroups[0].delete.map(\.id) == [id(1)])
|
||||
}
|
||||
|
||||
@Test func seedMemberNeverDeleted() throws {
|
||||
let seedSplit = split(id: id(1))
|
||||
let duplicate = split(id: id(2))
|
||||
let seedRoutine = routine(id: id(1))
|
||||
let duplicate = routine(id: id(2))
|
||||
let plan = DuplicateCleanupPlanner.plan(
|
||||
splits: [seedSplit, duplicate], workouts: [], referencedSplitIDs: [],
|
||||
routines: [seedRoutine, duplicate], workouts: [], referencedRoutineIDs: [],
|
||||
isSeed: { $0 == id(1) }
|
||||
)
|
||||
let group = try #require(plan.splitGroups.first)
|
||||
let group = try #require(plan.routineGroups.first)
|
||||
#expect(group.keep.map(\.id) == [id(1)])
|
||||
#expect(group.delete.map(\.id) == [id(2)])
|
||||
#expect(!group.delete.contains { $0.id == id(1) })
|
||||
}
|
||||
|
||||
@Test func differingExerciseContentNotGrouped() {
|
||||
let a = split(id: id(1), exercises: [exercise(weight: 100)])
|
||||
let b = split(id: id(2), exercises: [exercise(weight: 105)])
|
||||
let a = routine(id: id(1), exercises: [exercise(weight: 100)])
|
||||
let b = routine(id: id(2), exercises: [exercise(weight: 105)])
|
||||
let plan = DuplicateCleanupPlanner.plan(
|
||||
splits: [a, b], workouts: [], referencedSplitIDs: [], isSeed: { _ in false }
|
||||
routines: [a, b], workouts: [], referencedRoutineIDs: [], isSeed: { _ in false }
|
||||
)
|
||||
#expect(plan.splitGroups.isEmpty)
|
||||
#expect(plan.routineGroups.isEmpty)
|
||||
}
|
||||
|
||||
/// Machine comfort settings are real content: splits identical except for a
|
||||
/// Machine comfort settings are real content: routines identical except for a
|
||||
/// setting value — or for the nil (non-machine) vs. empty (machine, nothing
|
||||
/// recorded) distinction — must not be judged duplicates.
|
||||
@Test func differingMachineSettingsNotGrouped() {
|
||||
@@ -137,14 +137,14 @@ struct DuplicateCleanupPlannerTests {
|
||||
var machineNothingRecorded = exercise()
|
||||
machineNothingRecorded.machineSettings = []
|
||||
|
||||
let a = split(id: id(1), exercises: [lowSeat])
|
||||
let b = split(id: id(2), exercises: [highSeat])
|
||||
let c = split(id: id(3), exercises: [machineNothingRecorded])
|
||||
let d = split(id: id(4)) // machineSettings nil — not a machine exercise
|
||||
let a = routine(id: id(1), exercises: [lowSeat])
|
||||
let b = routine(id: id(2), exercises: [highSeat])
|
||||
let c = routine(id: id(3), exercises: [machineNothingRecorded])
|
||||
let d = routine(id: id(4)) // machineSettings nil — not a machine exercise
|
||||
let plan = DuplicateCleanupPlanner.plan(
|
||||
splits: [a, b, c, d], workouts: [], referencedSplitIDs: [], isSeed: { _ in false }
|
||||
routines: [a, b, c, d], workouts: [], referencedRoutineIDs: [], isSeed: { _ in false }
|
||||
)
|
||||
#expect(plan.splitGroups.isEmpty)
|
||||
#expect(plan.routineGroups.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - Workout grouping / survivor rules
|
||||
@@ -155,7 +155,7 @@ struct DuplicateCleanupPlannerTests {
|
||||
let w1 = workout(id: id(1), start: day)
|
||||
let w2 = workout(id: id(2), start: sameDayLater)
|
||||
let plan = DuplicateCleanupPlanner.plan(
|
||||
splits: [], workouts: [w1, w2], referencedSplitIDs: [], isSeed: { _ in false }
|
||||
routines: [], workouts: [w1, w2], referencedRoutineIDs: [], isSeed: { _ in false }
|
||||
)
|
||||
#expect(plan.workoutGroups.count == 1)
|
||||
#expect(plan.workoutGroups[0].keep.id == id(1))
|
||||
@@ -166,7 +166,7 @@ struct DuplicateCleanupPlannerTests {
|
||||
let w1 = workout(id: id(1), start: fixedDate(day: 15))
|
||||
let w2 = workout(id: id(2), start: fixedDate(day: 16))
|
||||
let plan = DuplicateCleanupPlanner.plan(
|
||||
splits: [], workouts: [w1, w2], referencedSplitIDs: [], isSeed: { _ in false }
|
||||
routines: [], workouts: [w1, w2], referencedRoutineIDs: [], isSeed: { _ in false }
|
||||
)
|
||||
#expect(plan.workoutGroups.isEmpty)
|
||||
}
|
||||
@@ -176,7 +176,7 @@ struct DuplicateCleanupPlannerTests {
|
||||
let w1 = workout(id: id(1), start: start, status: WorkoutStatus.inProgress.rawValue)
|
||||
let w2 = workout(id: id(2), start: start, status: WorkoutStatus.inProgress.rawValue)
|
||||
let plan = DuplicateCleanupPlanner.plan(
|
||||
splits: [], workouts: [w1, w2], referencedSplitIDs: [], isSeed: { _ in false }
|
||||
routines: [], workouts: [w1, w2], referencedRoutineIDs: [], isSeed: { _ in false }
|
||||
)
|
||||
#expect(plan.workoutGroups.isEmpty)
|
||||
}
|
||||
@@ -189,7 +189,7 @@ struct DuplicateCleanupPlannerTests {
|
||||
let earlierNoLink = workout(id: id(1), start: start, metrics: nil)
|
||||
let laterWithLink = workout(id: id(2), start: start, metrics: metrics(healthKitWorkoutUUID: "HK-UUID"))
|
||||
let plan = DuplicateCleanupPlanner.plan(
|
||||
splits: [], workouts: [earlierNoLink, laterWithLink], referencedSplitIDs: [], isSeed: { _ in false }
|
||||
routines: [], workouts: [earlierNoLink, laterWithLink], referencedRoutineIDs: [], isSeed: { _ in false }
|
||||
)
|
||||
let group = try #require(plan.workoutGroups.first)
|
||||
#expect(group.keep.id == id(2))
|
||||
|
||||
@@ -10,7 +10,7 @@ import Testing
|
||||
struct ExerciseLibraryQueryTests {
|
||||
|
||||
private func makeContext() throws -> ModelContext {
|
||||
let schema = Schema([Split.self, Exercise.self, Workout.self, WorkoutLog.self])
|
||||
let schema = Schema([Routine.self, Exercise.self, Workout.self, WorkoutLog.self])
|
||||
// cloudKitDatabase: .none matches WorkoutsModelContainer — see SeedLibraryTests.
|
||||
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true, cloudKitDatabase: .none)
|
||||
let container = try ModelContainer(for: schema, configurations: [config])
|
||||
@@ -19,15 +19,16 @@ struct ExerciseLibraryQueryTests {
|
||||
|
||||
@Test func machineSettingsFetchFindsRecordedSettings() throws {
|
||||
let context = try makeContext()
|
||||
let split = Split(id: "S1", name: "Upper Body", color: "indigo",
|
||||
// PINNED: routine documents live under the on-disk "Splits/" directory.
|
||||
let routine = Routine(id: "S1", name: "Upper Body", color: "indigo",
|
||||
systemImage: "dumbbell.fill", order: 0,
|
||||
createdAt: .now, updatedAt: .now, jsonRelativePath: "Splits/S1.json")
|
||||
let exercise = Exercise(id: "E1", name: "Chest Press", order: 0, sets: 3, reps: 10,
|
||||
weight: 100, loadType: LoadType.weight.rawValue,
|
||||
durationTotalSeconds: 0,
|
||||
machineSettings: [MachineSetting(name: "Seat Height", value: "4")])
|
||||
exercise.split = split
|
||||
context.insert(split)
|
||||
exercise.routine = routine
|
||||
context.insert(routine)
|
||||
context.insert(exercise)
|
||||
try context.save()
|
||||
|
||||
@@ -37,12 +38,12 @@ struct ExerciseLibraryQueryTests {
|
||||
))
|
||||
#expect(fetched.count == 1)
|
||||
#expect(fetched.first?.machineSettings == [MachineSetting(name: "Seat Height", value: "4")])
|
||||
#expect(fetched.first?.split?.name == "Upper Body")
|
||||
#expect(fetched.first?.routine?.name == "Upper Body")
|
||||
}
|
||||
|
||||
@Test func weightedLogFetchGatesChart() throws {
|
||||
let context = try makeContext()
|
||||
let workout = Workout(id: "W1", splitID: nil, splitName: "Upper Body",
|
||||
let workout = Workout(id: "W1", routineID: nil, routineName: "Upper Body",
|
||||
start: .now, end: .now, statusRaw: WorkoutStatus.completed.rawValue,
|
||||
createdAt: .now, updatedAt: .now,
|
||||
jsonRelativePath: "Workouts/2026/07/W1.json")
|
||||
|
||||
@@ -131,7 +131,7 @@ struct ExerciseMotionTests {
|
||||
bundle.url(forResource: "figure-fixtures", withExtension: "json"),
|
||||
"figure-fixtures.json must be bundled as a WorkoutsTests resource (see project.yml)")
|
||||
let fixtures = try JSONDecoder().decode(FigureFixtures.self, from: Data(contentsOf: url))
|
||||
#expect(fixtures.exercises.count == 64)
|
||||
#expect(fixtures.exercises.count == 66)
|
||||
|
||||
for exercise in fixtures.exercises {
|
||||
let resources = try #require(ExerciseMotionLibrary.resources(for: exercise.name),
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,84 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Workouts
|
||||
|
||||
/// Locks `GoalKind`'s persisted-order contract: the stored raw-value list drives the
|
||||
/// display order, unknown raw values are dropped, and any case missing from the stored
|
||||
/// list is appended in declaration order — so a newly shipped goal kind surfaces
|
||||
/// automatically without a migration. A throwaway `UserDefaults` suite keeps each case
|
||||
/// isolated from the real domain.
|
||||
struct GoalKindTests {
|
||||
|
||||
/// A fresh suite scoped to one test; cleared on teardown so nothing leaks between runs.
|
||||
private func makeDefaults(_ function: String = #function) -> UserDefaults {
|
||||
let suite = "GoalKindTests.\(function).\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defaults.removePersistentDomain(forName: suite)
|
||||
return defaults
|
||||
}
|
||||
|
||||
/// With nothing stored, the order is exactly declaration order (all cases present).
|
||||
@Test func emptyDefaultsGivesDeclarationOrder() {
|
||||
let defaults = makeDefaults()
|
||||
#expect(GoalKind.orderedCases(defaults: defaults) == GoalKind.allCases)
|
||||
#expect(GoalKind.orderedCases(defaults: defaults) == [.mobility, .cardio, .strength, .mindfulness])
|
||||
}
|
||||
|
||||
/// A partial stored list keeps the stored ones first (in stored order), then appends
|
||||
/// the missing cases in declaration order — the "new kind appears automatically" rule.
|
||||
@Test func partialStoredListPutsStoredFirstThenMissing() {
|
||||
let defaults = makeDefaults()
|
||||
GoalKind.saveOrder([.mindfulness, .mobility], defaults: defaults)
|
||||
|
||||
let ordered = GoalKind.orderedCases(defaults: defaults)
|
||||
#expect(ordered == [.mindfulness, .mobility, .cardio, .strength])
|
||||
}
|
||||
|
||||
/// A full stored list is honored verbatim — a user-chosen reorder round-trips.
|
||||
@Test func fullStoredListIsHonoredVerbatim() {
|
||||
let defaults = makeDefaults()
|
||||
let chosen: [GoalKind] = [.cardio, .strength, .mindfulness, .mobility]
|
||||
GoalKind.saveOrder(chosen, defaults: defaults)
|
||||
#expect(GoalKind.orderedCases(defaults: defaults) == chosen)
|
||||
}
|
||||
|
||||
/// Unknown raw values in the stored list are dropped (e.g. a kind removed in a future
|
||||
/// version), and the survivors still slot ahead of the missing declaration-order tail.
|
||||
@Test func unknownRawValuesAreDropped() {
|
||||
let defaults = makeDefaults()
|
||||
defaults.set(["mindfulness", "flexibility", "strength"], forKey: GoalKind.orderDefaultsKey)
|
||||
|
||||
let ordered = GoalKind.orderedCases(defaults: defaults)
|
||||
#expect(ordered == [.mindfulness, .strength, .mobility, .cardio])
|
||||
}
|
||||
|
||||
/// Duplicate raw values collapse to a single entry (first wins), so a corrupt list
|
||||
/// never yields a kind twice.
|
||||
@Test func duplicateRawValuesCollapse() {
|
||||
let defaults = makeDefaults()
|
||||
defaults.set(["strength", "strength", "mobility"], forKey: GoalKind.orderDefaultsKey)
|
||||
|
||||
let ordered = GoalKind.orderedCases(defaults: defaults)
|
||||
#expect(ordered == [.strength, .mobility, .cardio, .mindfulness])
|
||||
#expect(Set(ordered).count == ordered.count) // no dupes
|
||||
}
|
||||
|
||||
/// `saveOrder` persists `[String]` of raw values — the exact shape `orderedCases`
|
||||
/// reads back.
|
||||
@Test func saveOrderStoresRawValueStrings() {
|
||||
let defaults = makeDefaults()
|
||||
GoalKind.saveOrder([.mobility, .strength], defaults: defaults)
|
||||
#expect(defaults.stringArray(forKey: GoalKind.orderDefaultsKey) == ["mobility", "strength"])
|
||||
}
|
||||
|
||||
/// Every case carries a valid, distinct-hued color name `Color.color(from:)` maps
|
||||
/// to a non-default color, and stable display metadata.
|
||||
@Test func metadataIsPopulatedAndDistinct() {
|
||||
#expect(Set(GoalKind.allCases.map(\.colorName)).count == GoalKind.allCases.count)
|
||||
#expect(Set(GoalKind.allCases.map(\.systemImage)).count == GoalKind.allCases.count)
|
||||
#expect(GoalKind.strength.displayName == "Strength")
|
||||
#expect(GoalKind.mindfulness.displayName == "Mindfulness")
|
||||
// Each color name is one the palette understands (falls back to indigo otherwise).
|
||||
#expect(GoalKind.allCases.allSatisfy { availableColors.contains($0.colorName) })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
+12
-11
@@ -3,13 +3,13 @@ import Testing
|
||||
import IndieSync
|
||||
@testable import Workouts
|
||||
|
||||
/// Round-trips `SplitDocument` (and the embedded exercise / log shapes) through
|
||||
/// Round-trips `RoutineDocument` (and the embedded exercise / log shapes) through
|
||||
/// `DocumentCoder` — the exact JSON codec (ISO-8601 dates, sorted keys,
|
||||
/// pretty-printed) `DocumentFileStore` uses to write and read every document in
|
||||
/// this app's iCloud container. Also pins the schema-v2 migration: an old v1
|
||||
/// split file (with the removed weight-reminder / category keys) must still decode
|
||||
/// routine file (with the removed weight-reminder / category keys) must still decode
|
||||
/// and round-trip at the current version.
|
||||
struct SplitDocumentCodableTests {
|
||||
struct RoutineDocumentCodableTests {
|
||||
/// A whole-second epoch so ISO-8601 round-trips (no fractional-second loss).
|
||||
private static let stableTimestamp = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
|
||||
@@ -28,8 +28,8 @@ struct SplitDocumentCodableTests {
|
||||
MachineSetting(name: "Back Rest Incline", value: "45°")
|
||||
]
|
||||
)
|
||||
let original = SplitDocument(
|
||||
schemaVersion: SplitDocument.currentSchemaVersion,
|
||||
let original = RoutineDocument(
|
||||
schemaVersion: RoutineDocument.currentSchemaVersion,
|
||||
id: ULID.make(),
|
||||
name: "Push Day",
|
||||
color: "blue",
|
||||
@@ -42,10 +42,11 @@ struct SplitDocumentCodableTests {
|
||||
)
|
||||
|
||||
let data = try DocumentCoder.encode(original)
|
||||
let decoded = try DocumentCoder.decode(SplitDocument.self, from: data)
|
||||
let decoded = try DocumentCoder.decode(RoutineDocument.self, from: data)
|
||||
|
||||
#expect(decoded == original)
|
||||
#expect(decoded.schemaVersion == 3)
|
||||
// PINNED: routine documents live under the on-disk "Splits/" directory.
|
||||
#expect(decoded.relativePath == "Splits/\(original.id).json")
|
||||
#expect(decoded.exercises.first?.machineSettings?.count == 2)
|
||||
}
|
||||
@@ -98,13 +99,13 @@ struct SplitDocumentCodableTests {
|
||||
#expect(decoded.machineSettings == [MachineSetting(name: "Seat Height", value: "4")])
|
||||
}
|
||||
|
||||
/// A v1 split file carrying the since-removed keys (`weightLastUpdated`,
|
||||
/// A v1 routine file carrying the since-removed keys (`weightLastUpdated`,
|
||||
/// `weightReminderWeeks`, `category`) must still decode — Codable ignores the
|
||||
/// extra keys, every remaining field is present in old files, and the integer
|
||||
/// `weight` decodes natively into today's `Double`. Re-stamping `schemaVersion`
|
||||
/// to the current value (what the app does on any rewrite via the
|
||||
/// cache→document mapper) round-trips cleanly at the current version.
|
||||
@Test func decodesLegacyV1SplitAndRoundTripsAtCurrent() throws {
|
||||
@Test func decodesLegacyV1RoutineAndRoundTripsAtCurrent() throws {
|
||||
let json = """
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
@@ -130,7 +131,7 @@ struct SplitDocumentCodableTests {
|
||||
}]
|
||||
}
|
||||
"""
|
||||
var decoded = try DocumentCoder.decode(SplitDocument.self, from: Data(json.utf8))
|
||||
var decoded = try DocumentCoder.decode(RoutineDocument.self, from: Data(json.utf8))
|
||||
// Removed keys are ignored; the surviving fields decode and machineSettings
|
||||
// defaults to nil (the file predates it).
|
||||
#expect(decoded.schemaVersion == 1)
|
||||
@@ -140,9 +141,9 @@ struct SplitDocumentCodableTests {
|
||||
#expect(decoded.exercises.first?.machineSettings == nil)
|
||||
|
||||
// The app re-stamps to the current version on rewrite; that must round-trip.
|
||||
decoded.schemaVersion = SplitDocument.currentSchemaVersion
|
||||
decoded.schemaVersion = RoutineDocument.currentSchemaVersion
|
||||
let reData = try DocumentCoder.encode(decoded)
|
||||
let reDecoded = try DocumentCoder.decode(SplitDocument.self, from: reData)
|
||||
let reDecoded = try DocumentCoder.decode(RoutineDocument.self, from: reData)
|
||||
#expect(reDecoded == decoded)
|
||||
#expect(reDecoded.schemaVersion == 3)
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
import Testing
|
||||
import IndieSync
|
||||
@testable import Workouts
|
||||
|
||||
/// Round-trips `ScheduleDocument` through `DocumentCoder` (the exact JSON codec every
|
||||
/// document in this app's iCloud container is written with), pins its on-disk path and
|
||||
/// the `isReadable` forward-gate, and checks the convenience accessors + the recurrence
|
||||
/// summary shared with the `Schedule` cache entity. Also exercises the document ↔ cache
|
||||
/// mapper both directions, mirroring `WorkoutDocumentMapperTests` for the schedule aggregate.
|
||||
struct ScheduleDocumentTests {
|
||||
/// Whole-second epochs so ISO-8601 round-trips without fractional-second loss.
|
||||
private static let created = Date(timeIntervalSince1970: 1_699_999_000)
|
||||
private static let updated = Date(timeIntervalSince1970: 1_700_004_000)
|
||||
/// A fixed one-off day for `.once` schedules.
|
||||
private static let onceDay = Date(timeIntervalSince1970: 1_752_105_600)
|
||||
|
||||
private static let gregorianShortWeekdays = Calendar(identifier: .gregorian).shortWeekdaySymbols
|
||||
|
||||
// MARK: - Codable
|
||||
|
||||
/// A fully-populated `.fixedDays` schedule round-trips byte-for-byte through the codec,
|
||||
/// keeps its `Schedules/<id>.json` path, and stays readable at the current version.
|
||||
@Test func encodeDecodeRoundTripFixedDays() throws {
|
||||
let original = ScheduleDocument(
|
||||
schemaVersion: ScheduleDocument.currentSchemaVersion,
|
||||
id: ULID.make(),
|
||||
routineID: "01ROUTINEAAAAAAAAAAAAAAAAAA",
|
||||
routineName: "Push Day",
|
||||
goal: GoalKind.strength.rawValue,
|
||||
recurrence: ScheduleRecurrence.fixedDays.rawValue,
|
||||
weekdays: [2, 5],
|
||||
timesPerWeek: nil,
|
||||
order: 3,
|
||||
createdAt: Self.created,
|
||||
updatedAt: Self.updated
|
||||
)
|
||||
|
||||
let data = try DocumentCoder.encode(original)
|
||||
let decoded = try DocumentCoder.decode(ScheduleDocument.self, from: data)
|
||||
|
||||
#expect(decoded == original)
|
||||
#expect(decoded.schemaVersion == 1)
|
||||
#expect(decoded.relativePath == "Schedules/\(original.id).json")
|
||||
#expect(decoded.isReadable)
|
||||
#expect(decoded.goalKind == .strength)
|
||||
#expect(decoded.recurrenceEnum == .fixedDays)
|
||||
#expect(decoded.timesPerWeek == nil)
|
||||
}
|
||||
|
||||
/// The nil-heavy cases round-trip too: a `.daily` schedule with no goal, no weekdays,
|
||||
/// and no frequency count. Absent optionals decode back to nil (not phantom values).
|
||||
@Test func encodeDecodeRoundTripDailyWithNils() throws {
|
||||
let original = ScheduleDocument(
|
||||
schemaVersion: ScheduleDocument.currentSchemaVersion,
|
||||
id: ULID.make(),
|
||||
routineID: "01ROUTINEBBBBBBBBBBBBBBBBBB",
|
||||
routineName: "Recovery",
|
||||
goal: nil,
|
||||
recurrence: ScheduleRecurrence.daily.rawValue,
|
||||
weekdays: nil,
|
||||
timesPerWeek: nil,
|
||||
order: 0,
|
||||
createdAt: Self.created,
|
||||
updatedAt: Self.updated
|
||||
)
|
||||
|
||||
let data = try DocumentCoder.encode(original)
|
||||
let json = String(decoding: data, as: UTF8.self)
|
||||
// nil optionals are omitted from the JSON entirely. ("date" is checked as a
|
||||
// quoted key — "updatedAt" contains it as a substring.)
|
||||
#expect(!json.contains("goal"))
|
||||
#expect(!json.contains("weekdays"))
|
||||
#expect(!json.contains("timesPerWeek"))
|
||||
#expect(!json.contains("\"date\""))
|
||||
|
||||
let decoded = try DocumentCoder.decode(ScheduleDocument.self, from: data)
|
||||
#expect(decoded == original)
|
||||
#expect(decoded.goal == nil)
|
||||
#expect(decoded.goalKind == nil)
|
||||
#expect(decoded.weekdays == nil)
|
||||
#expect(decoded.timesPerWeek == nil)
|
||||
#expect(decoded.date == nil)
|
||||
}
|
||||
|
||||
/// A `.once` schedule carries its one-off day and drops the recurring-only fields.
|
||||
@Test func encodeDecodeRoundTripOnce() throws {
|
||||
let original = ScheduleDocument(
|
||||
schemaVersion: ScheduleDocument.currentSchemaVersion,
|
||||
id: ULID.make(), routineID: "R", routineName: "Trail Run",
|
||||
goal: GoalKind.cardio.rawValue,
|
||||
recurrence: ScheduleRecurrence.once.rawValue,
|
||||
weekdays: nil, timesPerWeek: nil, date: Self.onceDay, order: 2,
|
||||
createdAt: Self.created, updatedAt: Self.updated
|
||||
)
|
||||
let decoded = try DocumentCoder.decode(ScheduleDocument.self, from: try DocumentCoder.encode(original))
|
||||
#expect(decoded == original)
|
||||
#expect(decoded.recurrenceEnum == .once)
|
||||
#expect(decoded.date == Self.onceDay)
|
||||
#expect(decoded.weekdays == nil)
|
||||
#expect(decoded.timesPerWeek == nil)
|
||||
}
|
||||
|
||||
/// A `.frequency` schedule carries its count and drops weekdays.
|
||||
@Test func encodeDecodeRoundTripFrequency() throws {
|
||||
let original = ScheduleDocument(
|
||||
schemaVersion: ScheduleDocument.currentSchemaVersion,
|
||||
id: ULID.make(), routineID: "R", routineName: "Cardio",
|
||||
goal: GoalKind.cardio.rawValue,
|
||||
recurrence: ScheduleRecurrence.frequency.rawValue,
|
||||
weekdays: nil, timesPerWeek: 3, order: 1,
|
||||
createdAt: Self.created, updatedAt: Self.updated
|
||||
)
|
||||
let decoded = try DocumentCoder.decode(ScheduleDocument.self, from: try DocumentCoder.encode(original))
|
||||
#expect(decoded == original)
|
||||
#expect(decoded.recurrenceEnum == .frequency)
|
||||
#expect(decoded.timesPerWeek == 3)
|
||||
}
|
||||
|
||||
// MARK: - Forward-compatibility gate
|
||||
|
||||
/// A schedule written by a newer app version (schemaVersion + 1) fails the `isReadable`
|
||||
/// gate, so the sync layer quarantines rather than downgrades it; at (and below) the
|
||||
/// current version it reads.
|
||||
@Test func forwardGateQuarantinesNewerVersions() {
|
||||
func doc(schema: Int) -> ScheduleDocument {
|
||||
ScheduleDocument(schemaVersion: schema, id: "01X", routineID: "R", routineName: "N",
|
||||
goal: nil, recurrence: ScheduleRecurrence.daily.rawValue,
|
||||
weekdays: nil, timesPerWeek: nil, order: 0,
|
||||
createdAt: Self.created, updatedAt: Self.updated)
|
||||
}
|
||||
#expect(doc(schema: ScheduleDocument.currentSchemaVersion).isReadable)
|
||||
#expect(doc(schema: ScheduleDocument.currentSchemaVersion - 1).isReadable)
|
||||
#expect(!doc(schema: ScheduleDocument.currentSchemaVersion + 1).isReadable)
|
||||
}
|
||||
|
||||
// MARK: - Recurrence summary
|
||||
|
||||
/// The recurrence summary formats each recurrence kind and shares its logic across the
|
||||
/// document and the cache entity. Weekday names are pulled from a fixed Gregorian
|
||||
/// calendar (1 = Sun … 7 = Sat) so the mapping is locale-order-stable.
|
||||
@Test func recurrenceSummaryFormatsEachKind() {
|
||||
let sym = Self.gregorianShortWeekdays
|
||||
|
||||
// Once — the one-off day, medium-date formatted (routing check; the format
|
||||
// itself is `Date.formatDate()`'s). Degenerate nil date → the display name.
|
||||
#expect(summary(.once, weekdays: nil, timesPerWeek: nil, date: Self.onceDay) == Self.onceDay.formatDate())
|
||||
#expect(summary(.once, weekdays: nil, timesPerWeek: nil) == "Once")
|
||||
|
||||
// Daily.
|
||||
#expect(summary(.daily, weekdays: nil, timesPerWeek: nil) == "Daily")
|
||||
|
||||
// Fixed days — sorted, joined with "&"; weekday 2 = Mon, 5 = Thu.
|
||||
#expect(summary(.fixedDays, weekdays: [5, 2], timesPerWeek: nil) == "\(sym[1]) & \(sym[4])")
|
||||
// Single day → just the name.
|
||||
#expect(summary(.fixedDays, weekdays: [2], timesPerWeek: nil) == sym[1])
|
||||
// Three days → Oxford-style "A, B & C".
|
||||
#expect(summary(.fixedDays, weekdays: [2, 4, 6], timesPerWeek: nil) == "\(sym[1]), \(sym[3]) & \(sym[5])")
|
||||
// Degenerate (no weekdays) → falls back to the recurrence display name.
|
||||
#expect(summary(.fixedDays, weekdays: [], timesPerWeek: nil) == "Fixed Days")
|
||||
#expect(summary(.fixedDays, weekdays: nil, timesPerWeek: nil) == "Fixed Days")
|
||||
// Out-of-range weekday numbers are ignored.
|
||||
#expect(summary(.fixedDays, weekdays: [0, 2, 9], timesPerWeek: nil) == sym[1])
|
||||
|
||||
// Frequency.
|
||||
#expect(summary(.frequency, weekdays: nil, timesPerWeek: 2) == "2× per week")
|
||||
#expect(summary(.frequency, weekdays: nil, timesPerWeek: nil) == "0× per week")
|
||||
}
|
||||
|
||||
private func summary(_ r: ScheduleRecurrence, weekdays: [Int]?, timesPerWeek: Int?, date: Date? = nil) -> String {
|
||||
let doc = ScheduleDocument(
|
||||
schemaVersion: ScheduleDocument.currentSchemaVersion, id: "X", routineID: "R", routineName: "N",
|
||||
goal: nil, recurrence: r.rawValue, weekdays: weekdays, timesPerWeek: timesPerWeek,
|
||||
date: date, order: 0, createdAt: Self.created, updatedAt: Self.updated
|
||||
)
|
||||
// The entity must format identically to the document — this is the shared-logic check.
|
||||
let entity = Schedule(id: "X", routineID: "R", routineName: "N", goalRaw: nil,
|
||||
recurrenceRaw: r.rawValue, weekdays: weekdays, timesPerWeek: timesPerWeek,
|
||||
date: date, order: 0, createdAt: Self.created, updatedAt: Self.updated,
|
||||
jsonRelativePath: "")
|
||||
#expect(doc.recurrenceSummary == entity.recurrenceSummary)
|
||||
return doc.recurrenceSummary
|
||||
}
|
||||
|
||||
// MARK: - Mapper (document ↔ cache)
|
||||
|
||||
@MainActor
|
||||
private func makeContext() throws -> ModelContext {
|
||||
let schema = Schema([Routine.self, Exercise.self, Workout.self, WorkoutLog.self, Schedule.self])
|
||||
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true, cloudKitDatabase: .none)
|
||||
return ModelContext(try ModelContainer(for: schema, configurations: [config]))
|
||||
}
|
||||
|
||||
/// A document pushed through `upsertSchedule` (document → cache) and rebuilt via
|
||||
/// `ScheduleDocument(from:)` (cache → document) preserves every field in both directions.
|
||||
@MainActor
|
||||
@Test func mapperRoundTripPreservesEveryField() throws {
|
||||
let context = try makeContext()
|
||||
let original = ScheduleDocument(
|
||||
schemaVersion: ScheduleDocument.currentSchemaVersion,
|
||||
id: "01SCHEDULEROUNDTRIP0000001",
|
||||
routineID: "01ROUTINEXYZ00000000000000",
|
||||
routineName: "Leg Day",
|
||||
goal: GoalKind.mobility.rawValue,
|
||||
recurrence: ScheduleRecurrence.fixedDays.rawValue,
|
||||
weekdays: [2, 4, 6],
|
||||
timesPerWeek: nil,
|
||||
order: 7,
|
||||
createdAt: Self.created,
|
||||
updatedAt: Self.updated
|
||||
)
|
||||
|
||||
CacheMapper.upsertSchedule(original, relativePath: original.relativePath, into: context)
|
||||
let entity = try #require(CacheMapper.fetchSchedule(id: original.id, in: context))
|
||||
|
||||
// Entity mirrors the document field-for-field.
|
||||
#expect(entity.routineID == original.routineID)
|
||||
#expect(entity.routineName == original.routineName)
|
||||
#expect(entity.goalRaw == original.goal)
|
||||
#expect(entity.goalKind == .mobility)
|
||||
#expect(entity.recurrenceRaw == original.recurrence)
|
||||
#expect(entity.recurrenceEnum == .fixedDays)
|
||||
#expect(entity.weekdays == [2, 4, 6])
|
||||
#expect(entity.timesPerWeek == nil)
|
||||
#expect(entity.order == 7)
|
||||
#expect(entity.jsonRelativePath == "Schedules/\(original.id).json")
|
||||
|
||||
// Cache → document round-trips back to the original.
|
||||
let rebuilt = ScheduleDocument(from: entity)
|
||||
#expect(rebuilt == original)
|
||||
}
|
||||
|
||||
/// The one-off day survives document → cache (`upsertSchedule`) → document — both on
|
||||
/// first insert and when re-upserting clears it back to nil.
|
||||
@MainActor
|
||||
@Test func mapperPreservesOnceDate() throws {
|
||||
let context = try makeContext()
|
||||
let original = ScheduleDocument(
|
||||
schemaVersion: ScheduleDocument.currentSchemaVersion,
|
||||
id: "01SCHEDULEONCE000000000001", routineID: "R", routineName: "Trail Run",
|
||||
goal: nil, recurrence: ScheduleRecurrence.once.rawValue,
|
||||
weekdays: nil, timesPerWeek: nil, date: Self.onceDay, order: 0,
|
||||
createdAt: Self.created, updatedAt: Self.updated
|
||||
)
|
||||
CacheMapper.upsertSchedule(original, relativePath: original.relativePath, into: context)
|
||||
let entity = try #require(CacheMapper.fetchSchedule(id: original.id, in: context))
|
||||
#expect(entity.date == Self.onceDay)
|
||||
#expect(entity.recurrenceEnum == .once)
|
||||
#expect(ScheduleDocument(from: entity) == original)
|
||||
|
||||
// Editing it into a recurring schedule drops the day on re-upsert.
|
||||
var second = original
|
||||
second.recurrence = ScheduleRecurrence.daily.rawValue
|
||||
second.date = nil
|
||||
CacheMapper.upsertSchedule(second, relativePath: second.relativePath, into: context)
|
||||
#expect(entity.date == nil)
|
||||
#expect(ScheduleDocument(from: entity) == second)
|
||||
}
|
||||
|
||||
/// Re-upserting a changed document updates the existing entity in place (matched by
|
||||
/// id) rather than inserting a duplicate — the observer's re-import shape.
|
||||
@MainActor
|
||||
@Test func reUpsertUpdatesInPlace() throws {
|
||||
let context = try makeContext()
|
||||
let first = ScheduleDocument(
|
||||
schemaVersion: ScheduleDocument.currentSchemaVersion,
|
||||
id: "01SCHEDULEREUPSERT0000001", routineID: "R", routineName: "Old Name",
|
||||
goal: nil, recurrence: ScheduleRecurrence.daily.rawValue,
|
||||
weekdays: nil, timesPerWeek: nil, order: 0,
|
||||
createdAt: Self.created, updatedAt: Self.created
|
||||
)
|
||||
CacheMapper.upsertSchedule(first, relativePath: first.relativePath, into: context)
|
||||
try context.save()
|
||||
|
||||
var second = first
|
||||
second.routineName = "New Name"
|
||||
second.goal = GoalKind.strength.rawValue
|
||||
second.recurrence = ScheduleRecurrence.frequency.rawValue
|
||||
second.timesPerWeek = 4
|
||||
second.updatedAt = Self.updated
|
||||
CacheMapper.upsertSchedule(second, relativePath: second.relativePath, into: context)
|
||||
try context.save()
|
||||
|
||||
let all = try context.fetch(FetchDescriptor<Schedule>())
|
||||
#expect(all.count == 1) // updated in place, not duplicated
|
||||
let entity = try #require(all.first)
|
||||
#expect(entity.routineName == "New Name")
|
||||
#expect(entity.goalKind == .strength)
|
||||
#expect(entity.recurrenceEnum == .frequency)
|
||||
#expect(entity.timesPerWeek == 4)
|
||||
#expect(ScheduleDocument(from: entity) == second)
|
||||
}
|
||||
}
|
||||
@@ -4,30 +4,30 @@ import Testing
|
||||
import IndieSync
|
||||
@testable import Workouts
|
||||
|
||||
/// Locks the bundled starter-split library end to end: the catalog decodes and
|
||||
/// Locks the bundled starter-routine library end to end: the catalog decodes and
|
||||
/// orders correctly, the pinned seed ids never drift, a pristine save re-emits
|
||||
/// byte-identical JSON (no phantom iCloud churn), every seed passes the
|
||||
/// forward-compatibility gate, and `isPristine` tracks edits correctly through
|
||||
/// both the raw document and a SwiftData cache round-trip.
|
||||
struct SeedLibraryTests {
|
||||
|
||||
@Test func catalogLoadsThirteenSeedsInOrder() {
|
||||
@Test func catalogLoadsFourteenSeedsInOrder() {
|
||||
let seeds = SeedLibrary.seeds
|
||||
#expect(seeds.count == 13)
|
||||
#expect(seeds.map(\.doc.order) == Array(0...12))
|
||||
#expect(seeds.count == 14)
|
||||
#expect(seeds.map(\.doc.order) == Array(0...13))
|
||||
#expect(seeds.map(\.doc.name) == [
|
||||
"Upper Body", "Core", "Lower Body", "Bodyweight Core",
|
||||
"Full Body Machines", "Free Weight Basics",
|
||||
"Upper Body Warm-Up", "Lower Body Warm-Up",
|
||||
"Morning Wake-Up", "Morning Mobility",
|
||||
"Full Body Stretch", "Evening Stretch", "Cardio",
|
||||
"Full Body Stretch", "Evening Stretch", "Cardio", "Meditation",
|
||||
])
|
||||
}
|
||||
|
||||
/// These ids are minted from a frozen 2020 timestamp and are already shipped
|
||||
/// inside installed app bundles. Changing any of them breaks cross-device
|
||||
/// convergence (two devices would mint different ids for "the same" starter
|
||||
/// split) and voids the permanent-tombstone exemption that lets an
|
||||
/// routine) and voids the permanent-tombstone exemption that lets an
|
||||
/// edited-then-deleted seed's stub veto resurrection forever — regenerate
|
||||
/// only with extreme care, and never for a seed that has already shipped.
|
||||
@Test func seedIDsArePinned() {
|
||||
@@ -45,6 +45,7 @@ struct SeedLibraryTests {
|
||||
"01DXF6DT003PRJB3FGYSSGTQ5X", // Full Body Stretch
|
||||
"01DXF6DT00M5YRFPHJQ2FS2B9P", // Evening Stretch
|
||||
"01DXF6DT00BXJ6N63ZBX9M9VNH", // Cardio
|
||||
"01DXF6DT00ANFJR7GX9G808G4M", // Meditation
|
||||
])
|
||||
}
|
||||
|
||||
@@ -59,7 +60,7 @@ struct SeedLibraryTests {
|
||||
@Test func allSeedsAreReadableAtCurrentSchemaVersion() {
|
||||
for seed in SeedLibrary.seeds {
|
||||
#expect(seed.doc.isReadable)
|
||||
#expect(seed.doc.schemaVersion == SplitDocument.currentSchemaVersion)
|
||||
#expect(seed.doc.schemaVersion == RoutineDocument.currentSchemaVersion)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,13 +103,13 @@ struct SeedLibraryTests {
|
||||
}
|
||||
|
||||
/// Round-trips every seed through the SwiftData cache the way the metadata
|
||||
/// observer would (document → `CacheMapper.upsertSplit` → entity), then
|
||||
/// observer would (document → `CacheMapper.upsertRoutine` → entity), then
|
||||
/// rebuilds a document from that entity the way the write path would. The
|
||||
/// result must still read as pristine, or every user's very first cache
|
||||
/// rebuild would silently fork their starter splits.
|
||||
/// rebuild would silently fork their starter routines.
|
||||
@MainActor
|
||||
@Test func mapperRoundTripPreservesPristineness() throws {
|
||||
let schema = Schema([Split.self, Exercise.self, Workout.self, WorkoutLog.self])
|
||||
let schema = Schema([Routine.self, Exercise.self, Workout.self, WorkoutLog.self])
|
||||
// cloudKitDatabase: .none matches WorkoutsModelContainer — without it SwiftData
|
||||
// applies CloudKit schema validation (all relationships optional), which the
|
||||
// cache entities intentionally don't satisfy.
|
||||
@@ -117,9 +118,9 @@ struct SeedLibraryTests {
|
||||
let context = ModelContext(container)
|
||||
|
||||
for seed in SeedLibrary.seeds {
|
||||
CacheMapper.upsertSplit(seed.doc, relativePath: seed.doc.relativePath, into: context)
|
||||
let entity = try #require(CacheMapper.fetchSplit(id: seed.id, in: context))
|
||||
let rebuilt = SplitDocument(from: entity)
|
||||
CacheMapper.upsertRoutine(seed.doc, relativePath: seed.doc.relativePath, into: context)
|
||||
let entity = try #require(CacheMapper.fetchRoutine(id: seed.id, in: context))
|
||||
let rebuilt = RoutineDocument(from: entity)
|
||||
#expect(SeedLibrary.isPristine(rebuilt))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import IndieSync
|
||||
|
||||
/// Locks the decision table of `SeedReconcilePlanner` — the safety-critical logic that
|
||||
/// upgrades stale starter-seed revisions and adds newly-shipped seeds on an existing
|
||||
/// install without ever duplicating a user's split, resurrecting a deleted seed, or
|
||||
/// install without ever duplicating a user's routine, resurrecting a deleted seed, or
|
||||
/// downgrade-rewriting a newer app version's file. All fixtures are plain structs so
|
||||
/// every assertion is a pure, deterministic function of the inputs.
|
||||
struct SeedReconcilePlannerTests {
|
||||
@@ -21,15 +21,15 @@ struct SeedReconcilePlannerTests {
|
||||
)
|
||||
}
|
||||
|
||||
/// A seed-shaped split at the current schema version, with a fixed frozen date so
|
||||
/// A seed-shaped routine at the current schema version, with a fixed frozen date so
|
||||
/// two builds of "the same" content compare equal.
|
||||
private func seed(
|
||||
id: String = "01DXF6DT0038BDC2WC3EVX8ZJ5",
|
||||
name: String = "Upper Body",
|
||||
schemaVersion: Int = SplitDocument.currentSchemaVersion,
|
||||
schemaVersion: Int = RoutineDocument.currentSchemaVersion,
|
||||
exercises: [ExerciseDocument]? = nil
|
||||
) -> SplitDocument {
|
||||
SplitDocument(
|
||||
) -> RoutineDocument {
|
||||
RoutineDocument(
|
||||
schemaVersion: schemaVersion, id: id, name: name, color: "indigo",
|
||||
systemImage: "dumbbell.fill", order: 0,
|
||||
createdAt: Date(timeIntervalSince1970: 0), updatedAt: Date(timeIntervalSince1970: 0),
|
||||
@@ -49,7 +49,7 @@ struct SeedReconcilePlannerTests {
|
||||
live.schemaVersion = 1 // older wrapper, same content
|
||||
let input = SeedReconcileInput(seedID: bundle.id, seedDoc: bundle, liveDoc: live, hasStub: false)
|
||||
|
||||
#expect(SeedReconcilePlanner.decision(for: input, liveSplitNames: []) == .skip(.upToDate))
|
||||
#expect(SeedReconcilePlanner.decision(for: input, liveRoutineNames: []) == .skip(.upToDate))
|
||||
}
|
||||
|
||||
/// The bundle seed gained an exercise since this install wrote its file — a real
|
||||
@@ -61,7 +61,7 @@ struct SeedReconcilePlannerTests {
|
||||
live.schemaVersion = 1
|
||||
let input = SeedReconcileInput(seedID: bundle.id, seedDoc: bundle, liveDoc: live, hasStub: false)
|
||||
|
||||
#expect(SeedReconcilePlanner.decision(for: input, liveSplitNames: []) == .upgrade)
|
||||
#expect(SeedReconcilePlanner.decision(for: input, liveRoutineNames: []) == .upgrade)
|
||||
}
|
||||
|
||||
/// A weight tweak alone is enough of a content change to upgrade.
|
||||
@@ -70,7 +70,7 @@ struct SeedReconcilePlannerTests {
|
||||
let live = seed(exercises: [exercise(name: "Bench Press", weight: 115)])
|
||||
let input = SeedReconcileInput(seedID: bundle.id, seedDoc: bundle, liveDoc: live, hasStub: false)
|
||||
|
||||
#expect(SeedReconcilePlanner.decision(for: input, liveSplitNames: []) == .upgrade)
|
||||
#expect(SeedReconcilePlanner.decision(for: input, liveRoutineNames: []) == .upgrade)
|
||||
}
|
||||
|
||||
/// A live file written by a NEWER app version (schemaVersion above current) is
|
||||
@@ -78,11 +78,11 @@ struct SeedReconcilePlannerTests {
|
||||
@Test func newerSchemaFileIsQuarantined() {
|
||||
let bundle = seed()
|
||||
var live = seed(exercises: [exercise(name: "Something New")])
|
||||
live.schemaVersion = SplitDocument.currentSchemaVersion + 1
|
||||
live.schemaVersion = RoutineDocument.currentSchemaVersion + 1
|
||||
#expect(!live.isReadable) // guards the premise
|
||||
let input = SeedReconcileInput(seedID: bundle.id, seedDoc: bundle, liveDoc: live, hasStub: false)
|
||||
|
||||
#expect(SeedReconcilePlanner.decision(for: input, liveSplitNames: []) == .skip(.quarantined))
|
||||
#expect(SeedReconcilePlanner.decision(for: input, liveRoutineNames: []) == .skip(.quarantined))
|
||||
}
|
||||
|
||||
// MARK: - No live file
|
||||
@@ -92,25 +92,25 @@ struct SeedReconcilePlannerTests {
|
||||
let bundle = seed()
|
||||
let input = SeedReconcileInput(seedID: bundle.id, seedDoc: bundle, liveDoc: nil, hasStub: true)
|
||||
|
||||
#expect(SeedReconcilePlanner.decision(for: input, liveSplitNames: []) == .skip(.vetoed))
|
||||
#expect(SeedReconcilePlanner.decision(for: input, liveRoutineNames: []) == .skip(.vetoed))
|
||||
}
|
||||
|
||||
/// No live file, no stub, but a live split (a legacy runtime-built or user-cloned
|
||||
/// No live file, no stub, but a live routine (a legacy runtime-built or user-cloned
|
||||
/// "Upper Body") already uses the seed's name → writing it would duplicate by name.
|
||||
@Test func nameCollisionSkips() {
|
||||
let bundle = seed(name: "Upper Body")
|
||||
let input = SeedReconcileInput(seedID: bundle.id, seedDoc: bundle, liveDoc: nil, hasStub: false)
|
||||
|
||||
#expect(SeedReconcilePlanner.decision(for: input, liveSplitNames: ["Upper Body", "Core"]) == .skip(.nameCollision))
|
||||
#expect(SeedReconcilePlanner.decision(for: input, liveRoutineNames: ["Upper Body", "Core"]) == .skip(.nameCollision))
|
||||
}
|
||||
|
||||
/// No live file, no stub, no same-name split → a genuinely missing seed (newly
|
||||
/// No live file, no stub, no same-name routine → a genuinely missing seed (newly
|
||||
/// shipped, or a pre-fixed-ULID install) gets written.
|
||||
@Test func missingSeedIsWritten() {
|
||||
let bundle = seed(name: "Bodyweight Core")
|
||||
let input = SeedReconcileInput(seedID: bundle.id, seedDoc: bundle, liveDoc: nil, hasStub: false)
|
||||
|
||||
#expect(SeedReconcilePlanner.decision(for: input, liveSplitNames: ["Upper Body", "Core"]) == .write)
|
||||
#expect(SeedReconcilePlanner.decision(for: input, liveRoutineNames: ["Upper Body", "Core"]) == .write)
|
||||
}
|
||||
|
||||
/// A stub present but the same name also live: the veto is checked first, so it
|
||||
@@ -119,7 +119,7 @@ struct SeedReconcilePlannerTests {
|
||||
let bundle = seed(name: "Upper Body")
|
||||
let input = SeedReconcileInput(seedID: bundle.id, seedDoc: bundle, liveDoc: nil, hasStub: true)
|
||||
|
||||
#expect(SeedReconcilePlanner.decision(for: input, liveSplitNames: ["Upper Body"]) == .skip(.vetoed))
|
||||
#expect(SeedReconcilePlanner.decision(for: input, liveRoutineNames: ["Upper Body"]) == .skip(.vetoed))
|
||||
}
|
||||
|
||||
// MARK: - Restore
|
||||
@@ -128,10 +128,10 @@ struct SeedReconcilePlannerTests {
|
||||
/// the presence of a veto stub is irrelevant (restore lifts it).
|
||||
@Test func shouldRestoreOnlyWhenAbsentAndNameFree() {
|
||||
// Absent, name free → restore (whether or not a stub exists).
|
||||
#expect(SeedReconcilePlanner.shouldRestore(hasLiveFile: false, seedName: "Upper Body", liveSplitNames: []))
|
||||
#expect(SeedReconcilePlanner.shouldRestore(hasLiveFile: false, seedName: "Upper Body", liveRoutineNames: []))
|
||||
// Live file already present → reconcile handles any upgrade; restore leaves it.
|
||||
#expect(!SeedReconcilePlanner.shouldRestore(hasLiveFile: true, seedName: "Upper Body", liveSplitNames: []))
|
||||
// A live same-name split exists → don't duplicate it.
|
||||
#expect(!SeedReconcilePlanner.shouldRestore(hasLiveFile: false, seedName: "Upper Body", liveSplitNames: ["Upper Body"]))
|
||||
#expect(!SeedReconcilePlanner.shouldRestore(hasLiveFile: true, seedName: "Upper Body", liveRoutineNames: []))
|
||||
// A live same-name routine exists → don't duplicate it.
|
||||
#expect(!SeedReconcilePlanner.shouldRestore(hasLiveFile: false, seedName: "Upper Body", liveRoutineNames: ["Upper Body"]))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import IndieSync
|
||||
import SwiftData
|
||||
import Testing
|
||||
@testable import Workouts
|
||||
@@ -20,19 +21,70 @@ struct SyncEngineTests {
|
||||
|
||||
@MainActor
|
||||
private func makeEngine() throws -> SyncEngine {
|
||||
let schema = Schema([Split.self, Exercise.self, Workout.self, WorkoutLog.self])
|
||||
let schema = Schema([Routine.self, Exercise.self, Workout.self, WorkoutLog.self])
|
||||
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true, cloudKitDatabase: .none)
|
||||
return SyncEngine(container: try ModelContainer(for: schema, configurations: [config]))
|
||||
let backlogURL = FileManager.default.temporaryDirectory
|
||||
.appending(path: "SyncEngineTests-\(UUID().uuidString).json")
|
||||
return SyncEngine(container: try ModelContainer(for: schema, configurations: [config]),
|
||||
backlogURL: backlogURL)
|
||||
}
|
||||
|
||||
/// With no clone-on-edit fork recorded, `currentSplitID` is the identity — a view
|
||||
/// holding a split id resolves to that same id. This pins the redirect-follow loop's
|
||||
/// A minimal workout document with one log, both carrying the given statuses.
|
||||
private func makeWorkout(id: String, status: WorkoutStatus, logStatus: WorkoutStatus) -> WorkoutDocument {
|
||||
let now = Date()
|
||||
var log = WorkoutLogDocument(planFrom: ExerciseDocument(
|
||||
id: ULID.make(), name: "Seated Row", order: 0, sets: 3, reps: 10,
|
||||
weight: 90, loadType: LoadType.weight.rawValue, durationSeconds: 0), order: 0, date: now)
|
||||
log.status = logStatus.rawValue
|
||||
return WorkoutDocument(
|
||||
schemaVersion: WorkoutDocument.currentSchemaVersion, id: id,
|
||||
routineID: nil, routineName: "Upper Body", start: now, end: nil,
|
||||
status: status.rawValue, createdAt: now, updatedAt: now, logs: [log])
|
||||
}
|
||||
|
||||
/// Locks `onWorkoutBecameActive`'s trigger table: it fires exactly once, on a
|
||||
/// phone-side `.notStarted` → `.inProgress` transition — not on draft creation,
|
||||
/// not on a repeat in-progress save, not for a watch-originated update, and not
|
||||
/// when an old workout is edited from `.completed` back to `.inProgress`.
|
||||
@MainActor
|
||||
@Test func becameActiveFiresOnlyOnLocalNotStartedToInProgress() async throws {
|
||||
let engine = try makeEngine()
|
||||
var fired: [String] = []
|
||||
engine.onWorkoutBecameActive = { fired.append($0.id) }
|
||||
|
||||
// Draft creation: notStarted → no fire.
|
||||
let id = ULID.make()
|
||||
await engine.save(workout: makeWorkout(id: id, status: .notStarted, logStatus: .notStarted))
|
||||
#expect(fired.isEmpty)
|
||||
|
||||
// First exercise starts: notStarted → inProgress fires once.
|
||||
await engine.save(workout: makeWorkout(id: id, status: .inProgress, logStatus: .inProgress))
|
||||
#expect(fired == [id])
|
||||
|
||||
// Re-saving an already-active run doesn't re-fire.
|
||||
await engine.save(workout: makeWorkout(id: id, status: .inProgress, logStatus: .inProgress))
|
||||
#expect(fired == [id])
|
||||
|
||||
// A watch-originated start never fires — the watch already owns a session.
|
||||
let watchID = ULID.make()
|
||||
await engine.ingestFromWatch(makeWorkout(id: watchID, status: .inProgress, logStatus: .inProgress))
|
||||
#expect(fired == [id])
|
||||
|
||||
// Editing a finished workout back to in-progress isn't a fresh start.
|
||||
let oldID = ULID.make()
|
||||
await engine.save(workout: makeWorkout(id: oldID, status: .completed, logStatus: .completed))
|
||||
await engine.save(workout: makeWorkout(id: oldID, status: .inProgress, logStatus: .inProgress))
|
||||
#expect(fired == [id])
|
||||
}
|
||||
|
||||
/// With no clone-on-edit fork recorded, `currentRoutineID` is the identity — a view
|
||||
/// holding a routine id resolves to that same id. This pins the redirect-follow loop's
|
||||
/// base case and its termination (no redirect → return input, no infinite loop).
|
||||
@MainActor
|
||||
@Test func currentSplitIDIsIdentityWithoutRedirects() throws {
|
||||
@Test func currentRoutineIDIsIdentityWithoutRedirects() throws {
|
||||
let engine = try makeEngine()
|
||||
#expect(engine.currentSplitID(for: "01SEEDABCDEFGHJKMNPQRSTVWX") == "01SEEDABCDEFGHJKMNPQRSTVWX")
|
||||
#expect(engine.currentSplitID(for: "") == "")
|
||||
#expect(engine.currentRoutineID(for: "01SEEDABCDEFGHJKMNPQRSTVWX") == "01SEEDABCDEFGHJKMNPQRSTVWX")
|
||||
#expect(engine.currentRoutineID(for: "") == "")
|
||||
}
|
||||
|
||||
/// A freshly constructed engine is in the `.checking` state until `connect()` runs,
|
||||
|
||||
@@ -4,7 +4,7 @@ import IndieSync
|
||||
@testable import Workouts
|
||||
|
||||
/// Round-trips every `WCPayload` wire shape — the iPhone↔Watch bridge's only
|
||||
/// serialization boundary. Splits/workouts ride as JSON `Data` blobs (via
|
||||
/// serialization boundary. Routines/workouts ride as JSON `Data` blobs (via
|
||||
/// `DocumentCoder`, ISO-8601), settings and edit locks as native plist scalars, and
|
||||
/// the ephemeral live-run frames as native values (so their timers keep sub-second
|
||||
/// precision). A field dropped or a `nil`-vs-empty confusion here silently desyncs
|
||||
@@ -14,9 +14,9 @@ struct WCPayloadRoundTripTests {
|
||||
|
||||
private static let ts = Date(timeIntervalSince1970: 1_700_000_000) // whole second: ISO-8601 safe
|
||||
|
||||
private func split(id: String, name: String) -> SplitDocument {
|
||||
SplitDocument(
|
||||
schemaVersion: SplitDocument.currentSchemaVersion, id: id, name: name, color: "blue",
|
||||
private func routine(id: String, name: String) -> RoutineDocument {
|
||||
RoutineDocument(
|
||||
schemaVersion: RoutineDocument.currentSchemaVersion, id: id, name: name, color: "blue",
|
||||
systemImage: "dumbbell.fill", order: 0, createdAt: Self.ts, updatedAt: Self.ts,
|
||||
exercises: [ExerciseDocument(id: "EX-\(id)", name: "Bench Press", order: 0, sets: 4,
|
||||
reps: 10, weight: 135, loadType: 1, durationSeconds: 0,
|
||||
@@ -27,7 +27,7 @@ struct WCPayloadRoundTripTests {
|
||||
|
||||
private func workout(id: String) -> WorkoutDocument {
|
||||
WorkoutDocument(
|
||||
schemaVersion: WorkoutDocument.currentSchemaVersion, id: id, splitID: "S", splitName: "Push",
|
||||
schemaVersion: WorkoutDocument.currentSchemaVersion, id: id, routineID: "S", routineName: "Push",
|
||||
start: Self.ts, end: Self.ts, status: WorkoutStatus.completed.rawValue,
|
||||
createdAt: Self.ts, updatedAt: Self.ts,
|
||||
logs: [WorkoutLogDocument(id: "L-\(id)", exerciseName: "Bench Press", order: 0, sets: 4,
|
||||
@@ -40,43 +40,43 @@ struct WCPayloadRoundTripTests {
|
||||
|
||||
// MARK: - Phone → Watch state + settings
|
||||
|
||||
@Test func stateRoundTripsSplitsWorkoutsAndSettings() {
|
||||
let splits = [split(id: "SP1", name: "Upper Body"), split(id: "SP2", name: "Lower Body")]
|
||||
@Test func stateRoundTripsRoutinesWorkoutsAndSettings() {
|
||||
let routines = [routine(id: "SP1", name: "Upper Body"), routine(id: "SP2", name: "Lower Body")]
|
||||
let workouts = [workout(id: "01WKA"), workout(id: "01WKB")]
|
||||
let dict = WCPayload.encodeState(
|
||||
splits: splits, workouts: workouts, restSeconds: 90, doneCountdownSeconds: 5,
|
||||
weightUnit: "kg", editingWorkoutID: "01WKA", editingSplitID: "SP1"
|
||||
routines: routines, workouts: workouts, restSeconds: 90, doneCountdownSeconds: 5,
|
||||
weightUnit: "kg", editingWorkoutID: "01WKA", editingRoutineID: "SP1"
|
||||
)
|
||||
|
||||
#expect(WCPayload.decodeSplits(dict) == splits)
|
||||
#expect(WCPayload.decodeRoutines(dict) == routines)
|
||||
#expect(WCPayload.decodeWorkouts(dict) == workouts)
|
||||
#expect(WCPayload.decodeRestSeconds(dict) == 90)
|
||||
#expect(WCPayload.decodeDoneCountdownSeconds(dict) == 5)
|
||||
#expect(WCPayload.decodeWeightUnit(dict) == "kg")
|
||||
#expect(WCPayload.decodeEditingWorkoutID(dict) == "01WKA")
|
||||
#expect(WCPayload.decodeEditingSplitID(dict) == "SP1")
|
||||
#expect(WCPayload.decodeEditingRoutineID(dict) == "SP1")
|
||||
}
|
||||
|
||||
/// Nil edit locks mean "not editing" → the keys are absent, so the decoders return
|
||||
/// nil (the receiver clears the lock). An empty splits/workouts list is legitimately
|
||||
/// nil (the receiver clears the lock). An empty routines/workouts list is legitimately
|
||||
/// empty — it still encodes a key and decodes back to `[]`, never nil.
|
||||
@Test func stateOmitsNilEditLocksAndKeepsEmptyListsDistinct() {
|
||||
let dict = WCPayload.encodeState(
|
||||
splits: [], workouts: [], restSeconds: 60, doneCountdownSeconds: 3,
|
||||
weightUnit: "lb", editingWorkoutID: nil, editingSplitID: nil
|
||||
routines: [], workouts: [], restSeconds: 60, doneCountdownSeconds: 3,
|
||||
weightUnit: "lb", editingWorkoutID: nil, editingRoutineID: nil
|
||||
)
|
||||
#expect(WCPayload.decodeEditingWorkoutID(dict) == nil)
|
||||
#expect(WCPayload.decodeEditingSplitID(dict) == nil)
|
||||
#expect(WCPayload.decodeEditingRoutineID(dict) == nil)
|
||||
// Authoritative empty push: an empty (present) list decodes to [], not nil.
|
||||
#expect(WCPayload.decodeSplits(dict) == [])
|
||||
#expect(WCPayload.decodeRoutines(dict) == [])
|
||||
#expect(WCPayload.decodeWorkouts(dict) == [])
|
||||
}
|
||||
|
||||
/// An absent splits/workouts key decodes to `[]` (nothing to apply), NOT nil — nil is
|
||||
/// An absent routines/workouts key decodes to `[]` (nothing to apply), NOT nil — nil is
|
||||
/// reserved for a genuine decode failure below.
|
||||
@Test func absentSplitsWorkoutsKeysDecodeToEmpty() {
|
||||
@Test func absentRoutinesWorkoutsKeysDecodeToEmpty() {
|
||||
let empty: [String: Any] = [:]
|
||||
#expect(WCPayload.decodeSplits(empty) == [])
|
||||
#expect(WCPayload.decodeRoutines(empty) == [])
|
||||
#expect(WCPayload.decodeWorkouts(empty) == [])
|
||||
}
|
||||
|
||||
@@ -85,8 +85,8 @@ struct WCPayloadRoundTripTests {
|
||||
/// pruning against a bogus set. This must stay distinct from an empty list.
|
||||
@Test func corruptStateDecodesToNilNotEmpty() {
|
||||
let garbage = Data([0xFF, 0xFE, 0x00, 0x01])
|
||||
let dict: [String: Any] = [WCPayload.splitsKey: garbage, WCPayload.workoutsKey: garbage]
|
||||
#expect(WCPayload.decodeSplits(dict) == nil)
|
||||
let dict: [String: Any] = [WCPayload.routinesKey: garbage, WCPayload.workoutsKey: garbage]
|
||||
#expect(WCPayload.decodeRoutines(dict) == nil)
|
||||
#expect(WCPayload.decodeWorkouts(dict) == nil)
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ struct WorkoutDocumentMapperTests {
|
||||
|
||||
@MainActor
|
||||
private func makeContext() throws -> ModelContext {
|
||||
let schema = Schema([Split.self, Exercise.self, Workout.self, WorkoutLog.self])
|
||||
let schema = Schema([Routine.self, Exercise.self, Workout.self, WorkoutLog.self])
|
||||
// cloudKitDatabase: .none matches WorkoutsModelContainer (see SeedLibraryTests).
|
||||
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true, cloudKitDatabase: .none)
|
||||
return ModelContext(try ModelContainer(for: schema, configurations: [config]))
|
||||
@@ -63,7 +63,7 @@ struct WorkoutDocumentMapperTests {
|
||||
let context = try makeContext()
|
||||
let original = WorkoutDocument(
|
||||
schemaVersion: WorkoutDocument.currentSchemaVersion,
|
||||
id: "01WORKOUTROUNDTRIP00000001", splitID: "SPLIT-42", splitName: "Push Day",
|
||||
id: "01WORKOUTROUNDTRIP00000001", routineID: "RT-42", routineName: "Push Day",
|
||||
start: Self.start, end: Self.end, status: WorkoutStatus.completed.rawValue,
|
||||
createdAt: Self.created, updatedAt: Self.updated,
|
||||
logs: [
|
||||
@@ -97,8 +97,8 @@ struct WorkoutDocumentMapperTests {
|
||||
|
||||
// Spot-pin the load-bearing fields the whole-document `==` rolls up, so a future
|
||||
// regression names itself.
|
||||
#expect(rebuilt.splitID == "SPLIT-42")
|
||||
#expect(rebuilt.splitName == "Push Day")
|
||||
#expect(rebuilt.routineID == "RT-42")
|
||||
#expect(rebuilt.routineName == "Push Day")
|
||||
#expect(rebuilt.start == Self.start)
|
||||
#expect(rebuilt.end == Self.end)
|
||||
#expect(rebuilt.createdAt == Self.created)
|
||||
@@ -131,7 +131,7 @@ struct WorkoutDocumentMapperTests {
|
||||
let context = try makeContext()
|
||||
let original = WorkoutDocument(
|
||||
schemaVersion: WorkoutDocument.currentSchemaVersion,
|
||||
id: "01WORKOUTNOMETRICS0000001", splitID: nil, splitName: nil,
|
||||
id: "01WORKOUTNOMETRICS0000001", routineID: nil, routineName: nil,
|
||||
start: Self.start, end: nil, status: WorkoutStatus.inProgress.rawValue,
|
||||
createdAt: Self.created, updatedAt: Self.updated,
|
||||
logs: [log(id: "LOG-X", name: "Plank", order: 0, status: .inProgress, startedAt: Self.startedAt)],
|
||||
@@ -144,7 +144,7 @@ struct WorkoutDocumentMapperTests {
|
||||
|
||||
#expect(rebuilt == original)
|
||||
#expect(rebuilt.metrics == nil)
|
||||
#expect(rebuilt.splitID == nil)
|
||||
#expect(rebuilt.routineID == nil)
|
||||
#expect(rebuilt.end == nil)
|
||||
}
|
||||
|
||||
@@ -156,7 +156,7 @@ struct WorkoutDocumentMapperTests {
|
||||
let context = try makeContext()
|
||||
let scrambled = WorkoutDocument(
|
||||
schemaVersion: WorkoutDocument.currentSchemaVersion,
|
||||
id: "01WORKOUTLOGORDER00000001", splitID: "S", splitName: "S",
|
||||
id: "01WORKOUTLOGORDER00000001", routineID: "S", routineName: "S",
|
||||
start: Self.start, end: Self.end, status: WorkoutStatus.completed.rawValue,
|
||||
createdAt: Self.created, updatedAt: Self.updated,
|
||||
logs: [
|
||||
@@ -184,7 +184,7 @@ struct WorkoutDocumentMapperTests {
|
||||
let context = try makeContext()
|
||||
let first = WorkoutDocument(
|
||||
schemaVersion: WorkoutDocument.currentSchemaVersion,
|
||||
id: "01WORKOUTREUPSERT00000001", splitID: "S", splitName: "S",
|
||||
id: "01WORKOUTREUPSERT00000001", routineID: "S", routineName: "S",
|
||||
start: Self.start, end: nil, status: WorkoutStatus.inProgress.rawValue,
|
||||
createdAt: Self.created, updatedAt: Self.updated,
|
||||
logs: [
|
||||
|
||||
@@ -26,8 +26,8 @@ struct WorkoutMergePlannerTests {
|
||||
logs: [WorkoutLogDocument], deletedLogIDs: [String: Date]? = nil,
|
||||
metrics: WorkoutMetrics? = nil, end: Date? = nil) -> WorkoutDocument {
|
||||
WorkoutDocument(
|
||||
schemaVersion: WorkoutDocument.currentSchemaVersion, id: "W", splitID: nil,
|
||||
splitName: nil, start: Self.t0, end: end, status: status.rawValue,
|
||||
schemaVersion: WorkoutDocument.currentSchemaVersion, id: "W", routineID: nil,
|
||||
routineName: nil, start: Self.t0, end: end, status: status.rawValue,
|
||||
createdAt: Self.t0, updatedAt: updatedAt, logs: logs, metrics: metrics,
|
||||
deletedLogIDs: deletedLogIDs)
|
||||
}
|
||||
|
||||
@@ -51,8 +51,8 @@ struct WorkoutPathBucketingTests {
|
||||
/// document's own bucket is exactly what the engine writes it to.
|
||||
@Test func instancePropertyMatchesStatic() {
|
||||
let doc = WorkoutDocument(
|
||||
schemaVersion: WorkoutDocument.currentSchemaVersion, id: "01WK", splitID: nil,
|
||||
splitName: nil, start: date(year: 2024, month: 7), end: nil,
|
||||
schemaVersion: WorkoutDocument.currentSchemaVersion, id: "01WK", routineID: nil,
|
||||
routineName: nil, start: date(year: 2024, month: 7), end: nil,
|
||||
status: WorkoutStatus.notStarted.rawValue, createdAt: date(year: 2024, month: 7),
|
||||
updatedAt: date(year: 2024, month: 7), logs: [], metrics: nil
|
||||
)
|
||||
|
||||
@@ -26,8 +26,8 @@ struct WorkoutStatusMachineTests {
|
||||
status: WorkoutStatus, end: Date?, logs: [WorkoutLogDocument]
|
||||
) -> WorkoutDocument {
|
||||
WorkoutDocument(
|
||||
schemaVersion: WorkoutDocument.currentSchemaVersion, id: "01WK", splitID: "S",
|
||||
splitName: "S", start: Self.start, end: end, status: status.rawValue,
|
||||
schemaVersion: WorkoutDocument.currentSchemaVersion, id: "01WK", routineID: "S",
|
||||
routineName: "S", start: Self.start, end: end, status: status.rawValue,
|
||||
createdAt: Self.start, updatedAt: Self.start, logs: logs, metrics: nil
|
||||
)
|
||||
}
|
||||
|
||||
@@ -22,10 +22,10 @@ struct WriteBacklogTests {
|
||||
return Calendar(identifier: .gregorian).date(from: comps)!
|
||||
}
|
||||
|
||||
private func workoutDoc(id: String, start: Date, updatedAt: Date, splitName: String? = nil) -> WorkoutDocument {
|
||||
private func workoutDoc(id: String, start: Date, updatedAt: Date, routineName: String? = nil) -> WorkoutDocument {
|
||||
WorkoutDocument(
|
||||
schemaVersion: WorkoutDocument.currentSchemaVersion, id: id, splitID: nil,
|
||||
splitName: splitName, start: start, end: nil,
|
||||
schemaVersion: WorkoutDocument.currentSchemaVersion, id: id, routineID: nil,
|
||||
routineName: routineName, start: start, end: nil,
|
||||
status: WorkoutStatus.notStarted.rawValue, createdAt: updatedAt, updatedAt: updatedAt,
|
||||
logs: [], metrics: nil
|
||||
)
|
||||
@@ -35,12 +35,12 @@ struct WriteBacklogTests {
|
||||
/// bookkeeping field, so replacement/retry/backoff tests can set up exact
|
||||
/// preconditions directly instead of driving them indirectly through `SyncEngine`.
|
||||
private func pendingWorkout(
|
||||
id: String, start: Date, timestamp: Date, enqueuedAt: Date, splitName: String? = nil,
|
||||
id: String, start: Date, timestamp: Date, enqueuedAt: Date, routineName: String? = nil,
|
||||
attempts: Int = 0, lastAttemptAt: Date? = nil, faultMessage: String? = nil,
|
||||
stalePaths: Set<String> = []
|
||||
) -> PendingWrite {
|
||||
PendingWrite(
|
||||
payload: .workout(workoutDoc(id: id, start: start, updatedAt: timestamp, splitName: splitName)),
|
||||
payload: .workout(workoutDoc(id: id, start: start, updatedAt: timestamp, routineName: routineName)),
|
||||
timestamp: timestamp, stalePaths: stalePaths, enqueuedAt: enqueuedAt,
|
||||
attempts: attempts, lastAttemptAt: lastAttemptAt, faultMessage: faultMessage
|
||||
)
|
||||
@@ -117,14 +117,14 @@ struct WriteBacklogTests {
|
||||
@Test func equalTimestampIncomingWinsSlot() throws {
|
||||
var backlog = WriteBacklog()
|
||||
let id = "01EQUALTIMESTAMP000000001"
|
||||
backlog.enqueue(pendingWorkout(id: id, start: Self.t0, timestamp: Self.t0, enqueuedAt: Self.t0, splitName: "Original"))
|
||||
backlog.enqueue(pendingWorkout(id: id, start: Self.t0, timestamp: Self.t0, enqueuedAt: Self.t0, splitName: "Incoming"))
|
||||
backlog.enqueue(pendingWorkout(id: id, start: Self.t0, timestamp: Self.t0, enqueuedAt: Self.t0, routineName: "Original"))
|
||||
backlog.enqueue(pendingWorkout(id: id, start: Self.t0, timestamp: Self.t0, enqueuedAt: Self.t0, routineName: "Incoming"))
|
||||
|
||||
let slot = try #require(backlog.pendingWrite(for: id))
|
||||
guard case .workout(let doc) = slot.payload else {
|
||||
Issue.record("expected a workout payload"); return
|
||||
}
|
||||
#expect(doc.splitName == "Incoming")
|
||||
#expect(doc.routineName == "Incoming")
|
||||
}
|
||||
|
||||
/// An incoming write strictly older than the occupant is dropped outright — the
|
||||
@@ -132,14 +132,14 @@ struct WriteBacklogTests {
|
||||
@Test func strictlyOlderIncomingIsDropped() throws {
|
||||
var backlog = WriteBacklog()
|
||||
let id = "01OLDERDROPPED0000000001"
|
||||
backlog.enqueue(pendingWorkout(id: id, start: Self.t0, timestamp: Self.t0, enqueuedAt: Self.t0, splitName: "Kept"))
|
||||
backlog.enqueue(pendingWorkout(id: id, start: Self.t0, timestamp: Self.t0.addingTimeInterval(-5), enqueuedAt: Self.t0, splitName: "Ignored"))
|
||||
backlog.enqueue(pendingWorkout(id: id, start: Self.t0, timestamp: Self.t0, enqueuedAt: Self.t0, routineName: "Kept"))
|
||||
backlog.enqueue(pendingWorkout(id: id, start: Self.t0, timestamp: Self.t0.addingTimeInterval(-5), enqueuedAt: Self.t0, routineName: "Ignored"))
|
||||
|
||||
let slot = try #require(backlog.pendingWrite(for: id))
|
||||
guard case .workout(let doc) = slot.payload else {
|
||||
Issue.record("expected a workout payload"); return
|
||||
}
|
||||
#expect(doc.splitName == "Kept")
|
||||
#expect(doc.routineName == "Kept")
|
||||
#expect(slot.timestamp == Self.t0)
|
||||
}
|
||||
|
||||
@@ -338,16 +338,16 @@ struct SyncEngineWriteQueueTests {
|
||||
|
||||
@MainActor
|
||||
private func makeEngine(backlogURL: URL) throws -> (engine: SyncEngine, container: ModelContainer) {
|
||||
let schema = Schema([Split.self, Exercise.self, Workout.self, WorkoutLog.self])
|
||||
let schema = Schema([Routine.self, Exercise.self, Workout.self, WorkoutLog.self])
|
||||
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true, cloudKitDatabase: .none)
|
||||
let container = try ModelContainer(for: schema, configurations: [config])
|
||||
return (SyncEngine(container: container, backlogURL: backlogURL), container)
|
||||
}
|
||||
|
||||
private func makeWorkoutDoc(id: String, updatedAt: Date, splitName: String? = "Push Day") -> WorkoutDocument {
|
||||
private func makeWorkoutDoc(id: String, updatedAt: Date, routineName: String? = "Push Day") -> WorkoutDocument {
|
||||
WorkoutDocument(
|
||||
schemaVersion: WorkoutDocument.currentSchemaVersion, id: id, splitID: "SPLIT-1",
|
||||
splitName: splitName, start: Self.t0, end: nil,
|
||||
schemaVersion: WorkoutDocument.currentSchemaVersion, id: id, routineID: "RT-1",
|
||||
routineName: routineName, start: Self.t0, end: nil,
|
||||
status: WorkoutStatus.notStarted.rawValue, createdAt: Self.t0, updatedAt: updatedAt,
|
||||
logs: [], metrics: nil
|
||||
)
|
||||
@@ -397,25 +397,25 @@ struct SyncEngineWriteQueueTests {
|
||||
@Test func ingestFromWatchAppliesUpdatedAtGate() async throws {
|
||||
let (engine, container) = try makeEngine(backlogURL: tempBacklogURL())
|
||||
let id = "01INGESTGATE000000000001"
|
||||
await engine.save(workout: makeWorkoutDoc(id: id, updatedAt: Self.t0, splitName: "Push Day"))
|
||||
await engine.save(workout: makeWorkoutDoc(id: id, updatedAt: Self.t0, routineName: "Push Day"))
|
||||
|
||||
// (a) strictly older — dropped, cache unchanged.
|
||||
await engine.ingestFromWatch(makeWorkoutDoc(id: id, updatedAt: Self.t0.addingTimeInterval(-60), splitName: "Push Day"))
|
||||
await engine.ingestFromWatch(makeWorkoutDoc(id: id, updatedAt: Self.t0.addingTimeInterval(-60), routineName: "Push Day"))
|
||||
var cached = try #require(CacheMapper.fetchWorkout(id: id, in: container.mainContext))
|
||||
#expect(cached.updatedAt == Self.t0)
|
||||
|
||||
// (b) same updatedAt but a changed field — the echo case, ignored entirely
|
||||
// (equal timestamp means duplicate, not a genuine edit).
|
||||
await engine.ingestFromWatch(makeWorkoutDoc(id: id, updatedAt: Self.t0, splitName: "Changed Name"))
|
||||
await engine.ingestFromWatch(makeWorkoutDoc(id: id, updatedAt: Self.t0, routineName: "Changed Name"))
|
||||
cached = try #require(CacheMapper.fetchWorkout(id: id, in: container.mainContext))
|
||||
#expect(cached.updatedAt == Self.t0)
|
||||
#expect(cached.splitName == "Push Day")
|
||||
#expect(cached.routineName == "Push Day")
|
||||
|
||||
// (c) strictly newer — accepted.
|
||||
await engine.ingestFromWatch(makeWorkoutDoc(id: id, updatedAt: Self.t0.addingTimeInterval(60), splitName: "Changed Name"))
|
||||
await engine.ingestFromWatch(makeWorkoutDoc(id: id, updatedAt: Self.t0.addingTimeInterval(60), routineName: "Changed Name"))
|
||||
cached = try #require(CacheMapper.fetchWorkout(id: id, in: container.mainContext))
|
||||
#expect(cached.updatedAt == Self.t0.addingTimeInterval(60))
|
||||
#expect(cached.splitName == "Changed Name")
|
||||
#expect(cached.routineName == "Changed Name")
|
||||
}
|
||||
|
||||
/// A queued-but-unwritten delete vetoes resurrection: a watch push racing the
|
||||
|
||||
Reference in New Issue
Block a user