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:
2026-07-11 07:53:01 -04:00
parent 5c201289fb
commit 6e440317c4
97 changed files with 5354 additions and 1291 deletions
@@ -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")
+1 -1
View File
@@ -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
+84
View File
@@ -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) })
}
}
+519
View File
@@ -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 MondaySunday weeks are
/// 06-2907-05 and 07-0607-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 (WedSun) 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
// (WedSun = 5), not the full 7-day week.
let creationWeek = day("2026-07-06")
let mark = ProgressPlanner.weekMark(schedule: daily, doneDays: [], weekStart: creationWeek,
calendar: cal, now: now)
#expect(mark?.expected == 5)
}
// MARK: - currentWeekBroken
@Test func currentWeekBrokenDailyMissedYesterdayVsUndoneToday() {
let daily = schedule(recurrence: .daily, createdAt: day("2026-06-01"))
let now = day("2026-07-08") // Wednesday; current week Mon 07-06 Sun 07-12
let weekStart = day("2026-07-06")
// Missed Tuesday: even finishing every remaining day (WedSun, 5 days) can't
// reach the full 7 the week is already unmeetable.
let missedYesterday: Set<Date> = [day("2026-07-06")]
#expect(ProgressPlanner.currentWeekBroken(schedule: daily, doneDays: missedYesterday,
weekStart: weekStart, calendar: cal, now: now))
// Monday and Tuesday both done; today (Wednesday) simply hasn't happened yet
// still fully recoverable, not broken.
let undoneToday: Set<Date> = [day("2026-07-06"), day("2026-07-07")]
#expect(!ProgressPlanner.currentWeekBroken(schedule: daily, doneDays: undoneToday,
weekStart: weekStart, calendar: cal, now: now))
}
@Test func 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)
}
}
@@ -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
/// cachedocument 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)
}
+294
View File
@@ -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)
}
}
+14 -13
View File
@@ -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))
}
}
+20 -20
View File
@@ -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"]))
}
}
+59 -7
View File
@@ -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,
+21 -21
View File
@@ -4,7 +4,7 @@ import IndieSync
@testable import Workouts
/// Round-trips every `WCPayload` wire shape the iPhoneWatch 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: [
+2 -2
View File
@@ -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
)
}
+21 -21
View File
@@ -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