Fix rebucketing, seed upgrades, watch pruning, end re-stamping; add model tests

Editing a workout's start date now removes the file at its old month
bucket so the record no longer duplicates on the next reconcile. Seed
reconcile re-checks the tombstone veto before overwriting an upgraded
seed. The watch applies authoritative-empty pushes so remote deletes
prune, and a re-saved finished workout keeps its original end time.
Adds unit tests for the mappers, path bucketing, and status machine.

Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
This commit is contained in:
2026-07-08 07:54:10 -04:00
parent a93cbc30b7
commit 06fa52345b
11 changed files with 697 additions and 8 deletions
+1 -1
View File
@@ -105,7 +105,7 @@ let seeds: [Sp] = [
Ex(name: "Hollow Body Hold", sets: 3, reps: 0, load: 2, dur: 30),
Ex(name: "Side Plank", sets: 3, reps: 0, load: 2, dur: 30),
Ex(name: "Leg Raises", sets: 3, reps: 12, load: 0),
Ex(name: "Bird Dog", sets: 3, reps: 8, load: 0),
Ex(name: "Crunch", sets: 3, reps: 12, load: 0),
Ex(name: "Reverse Crunch", sets: 3, reps: 12, load: 0),
]),
]
+6 -1
View File
@@ -118,8 +118,13 @@ extension WorkoutDocument {
let anyStarted = statuses.contains { $0 != .notStarted }
if allResolved {
// Only stamp the finish time on the *transition* into completed. A workout
// that was already completed (with an `end`) keeps that original end, so
// editing a finished workout which re-runs this recompute with all logs
// still resolved doesn't jump its end forward to now.
let wasCompletedWithEnd = status == WorkoutStatus.completed.rawValue && end != nil
status = WorkoutStatus.completed.rawValue
end = Date()
if !wasCompletedWithEnd { end = Date() }
} else if anyStarted {
status = WorkoutStatus.inProgress.rawValue
end = nil
@@ -212,7 +212,6 @@ final class WatchConnectivityBridge: NSObject {
private func applyState(_ splits: [SplitDocument], workouts: [WorkoutDocument]) {
Self.log.info("applyState: \(splits.count) splits, \(workouts.count) workouts")
guard !splits.isEmpty || !workouts.isEmpty else { return }
var liveSplitIDs = Set<String>()
for s in splits {
CacheMapper.upsertSplit(s, relativePath: s.relativePath, into: context)
@@ -77,9 +77,9 @@
"durationSeconds" : 0,
"id" : "01DXF6DT00MJH9N622QTMM7XB3",
"loadType" : 0,
"name" : "Bird Dog",
"name" : "Crunch",
"order" : 7,
"reps" : 8,
"reps" : 12,
"sets" : 3,
"weight" : 0
},
+23 -2
View File
@@ -331,9 +331,21 @@ final class SyncEngine {
func save(workout doc: WorkoutDocument) async {
guard let store else { return }
// The month bucket in a workout's path derives from `start`, so editing the
// start date (or a device in a different time zone) can move the file to a new
// path. Capture the previously-written path before the upsert overwrites it, so
// we can remove the stale file below otherwise the same id would live at two
// paths and the old copy would re-import on the next reconcile.
let previousPath = CacheMapper.fetchWorkout(id: doc.id, in: context)?.jsonRelativePath
do {
try await store.write(doc, to: doc.relativePath)
CacheMapper.upsertWorkout(doc, relativePath: doc.relativePath, into: context)
// Same id, new path: drop the orphaned file at the old bucket. The id lives
// on at the new path, so this is a plain removal no tombstone (a tombstone
// would veto the record that just moved). Phone stays the sole writer.
if let previousPath, previousPath != doc.relativePath {
try? await store.remove(at: previousPath)
}
saveCacheAndNotify()
lastSyncError = nil
} catch {
@@ -619,8 +631,17 @@ final class SyncEngine {
case .skip:
continue
case .upgrade:
// Re-check the veto against fresh state before overwriting symmetric
// with `.write`. The batch above was gathered before a settle-window
// race: if the user forked/soft-deleted this seed (clone-on-edit writes
// a stub and removes the live file) after we listed the live paths,
// rewriting the seed bytes here would transiently resurrect the deleted
// seed alongside the user's clone. The stub now present vetoes that.
// (No name-collision guard here: on a legitimate upgrade the seed itself
// is a live split by that name, so a name check would block every upgrade.)
if await tombstones.stubExists(id: seed.id) { continue }
// Overwriting an existing seed-path file with canonical bundle bytes is
// always safe (the file can only ever be an older seed revision).
// otherwise always safe (the file can only ever be an older seed revision).
if await writeSeedBytes(seed) { didChange = true }
case .write:
// Re-check the veto and name guards against fresh state the metadata
@@ -827,7 +848,7 @@ final class SyncEngine {
/// for the UI to render. Replaces silent `try?` / print-only handling.
private func report(_ message: String, _ error: Error? = nil) {
let full = error.map { "\(message): \($0.localizedDescription)" } ?? message
print("[Sync] \(full)")
log.error("\(full, privacy: .public)")
lastSyncError = full
}
}
+3 -1
View File
@@ -132,7 +132,8 @@ struct SettingsView: View {
Text("iCloud Sync")
}
// MARK: - Developer Section
// MARK: - Developer Section (debug builds only)
#if DEBUG
Section {
NavigationLink {
DuplicateCleanupView()
@@ -142,6 +143,7 @@ struct SettingsView: View {
} header: {
Text("Developer")
}
#endif
// MARK: - About Section
Section {
+47
View File
@@ -0,0 +1,47 @@
import Foundation
import SwiftData
import Testing
@testable import Workouts
/// Coverage for the parts of `SyncEngine` reachable without a live iCloud container.
///
/// SCOPE NOTE: `SyncEngine`'s orchestration (`save`/`delete`/`reconcile`/`reconcileSeeds`)
/// resolves its `DocumentFileStore` *inside* `connect()` from the real ubiquity
/// container there is no seam to inject a fake store, and the task deliberately rules
/// out building a heavyweight `NSMetadataQuery` mock. So the file-touching paths (incl.
/// FIX-1's rebucketing removal and FIX-2's `.upgrade` stub re-check) are not unit-testable
/// here without refactoring `SyncEngine` to accept an injected store out of scope.
/// Their *pure* decision logic is already locked elsewhere: `SeedReconcilePlannerTests`
/// (the seed skip/upgrade/write table, incl. the veto that FIX-2 re-checks),
/// `DuplicateCleanupPlannerTests`, `WorkoutPathBucketingTests` (FIX-1's path divergence),
/// and `WorkoutStatusMachineTests`. What remains directly testable is the pure
/// in-memory redirect helper below.
struct SyncEngineTests {
@MainActor
private func makeEngine() throws -> SyncEngine {
let schema = Schema([Split.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]))
}
/// 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
/// base case and its termination (no redirect return input, no infinite loop).
@MainActor
@Test func currentSplitIDIsIdentityWithoutRedirects() throws {
let engine = try makeEngine()
#expect(engine.currentSplitID(for: "01SEEDABCDEFGHJKMNPQRSTVWX") == "01SEEDABCDEFGHJKMNPQRSTVWX")
#expect(engine.currentSplitID(for: "") == "")
}
/// A freshly constructed engine is in the `.checking` state until `connect()` runs,
/// and exposes no sync error.
@MainActor
@Test func freshEngineIsCheckingWithNoError() throws {
let engine = try makeEngine()
#expect(engine.iCloudStatus == .checking)
#expect(engine.lastSyncError == nil)
#expect(!engine.isSyncing)
}
}
+155
View File
@@ -0,0 +1,155 @@
import Foundation
import Testing
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
/// `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
/// the two devices, so each direction is pinned end to end, including the
/// decode-failure contract (`nil` means "failed to decode", never "empty list").
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",
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,
machineSettings: [MachineSetting(name: "Seat", value: "4")])],
activityType: nil
)
}
private func workout(id: String) -> WorkoutDocument {
WorkoutDocument(
schemaVersion: WorkoutDocument.currentSchemaVersion, id: id, splitID: "S", splitName: "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,
reps: 10, weight: 135, loadType: 1, durationSeconds: 0,
currentStateIndex: 4, status: WorkoutStatus.completed.rawValue,
notes: "solid", date: Self.ts)],
metrics: nil
)
}
// MARK: - Phone Watch state + settings
@Test func stateRoundTripsSplitsWorkoutsAndSettings() {
let splits = [split(id: "SP1", name: "Upper Body"), split(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"
)
#expect(WCPayload.decodeSplits(dict) == splits)
#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")
}
/// 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
/// 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
)
#expect(WCPayload.decodeEditingWorkoutID(dict) == nil)
#expect(WCPayload.decodeEditingSplitID(dict) == nil)
// Authoritative empty push: an empty (present) list decodes to [], not nil.
#expect(WCPayload.decodeSplits(dict) == [])
#expect(WCPayload.decodeWorkouts(dict) == [])
}
/// An absent splits/workouts key decodes to `[]` (nothing to apply), NOT nil nil is
/// reserved for a genuine decode failure below.
@Test func absentSplitsWorkoutsKeysDecodeToEmpty() {
let empty: [String: Any] = [:]
#expect(WCPayload.decodeSplits(empty) == [])
#expect(WCPayload.decodeWorkouts(empty) == [])
}
/// A present-but-corrupt payload (a build-mismatch schema the receiver can't decode)
/// yields `nil` the signal the watch bridge's nil-decode guard uses to skip
/// 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)
#expect(WCPayload.decodeWorkouts(dict) == nil)
}
// MARK: - Watch Phone workout update
@Test func workoutUpdateRoundTrips() {
let w = workout(id: "01WKUPDATE")
let dict = WCPayload.encodeWorkoutUpdate(w)
#expect(dict[WCPayload.typeKey] as? String == WCPayload.workoutUpdateType)
#expect(WCPayload.decodeWorkoutUpdate(dict) == w)
}
@Test func workoutUpdateWithMissingBlobDecodesToNil() {
let dict: [String: Any] = [WCPayload.typeKey: WCPayload.workoutUpdateType]
#expect(WCPayload.decodeWorkoutUpdate(dict) == nil)
}
// MARK: - Watch Phone requestSync
@Test func requestSyncMessageCarriesType() {
let dict = WCPayload.requestSyncMessage()
#expect(dict[WCPayload.typeKey] as? String == WCPayload.requestSyncType)
}
// MARK: - Live-run mirror (ephemeral)
private func liveProgress(phaseEnd: Date?) -> LiveProgress {
LiveProgress(
workoutID: "01WKLIVE", logID: "LOG-LIVE", exerciseName: "Leg Press", phase: .rest,
setIndex: 2, setCount: 4, detail: "30 sec",
phaseStart: Date(timeIntervalSince1970: 1_700_000_123.75), // sub-second: proves native (not ISO-8601) transport
phaseEnd: phaseEnd, version: 7
)
}
@Test func liveProgressRoundTripsWithCountdownEnd() {
let frame = liveProgress(phaseEnd: Date(timeIntervalSince1970: 1_700_000_153.75))
let dict = WCPayload.encodeLiveProgress(frame)
#expect(dict[WCPayload.typeKey] as? String == WCPayload.liveProgressType)
#expect(WCPayload.decodeLiveProgress(dict) == frame)
}
/// A rep-based work set counts up and carries no `phaseEnd`; the key must be omitted
/// and decode back to nil, not a zero date.
@Test func liveProgressRoundTripsWithoutEnd() {
let frame = liveProgress(phaseEnd: nil)
let dict = WCPayload.encodeLiveProgress(frame)
#expect(dict[WCPayload.lpEndKey] == nil)
let decoded = WCPayload.decodeLiveProgress(dict)
#expect(decoded == frame)
#expect(decoded?.phaseEnd == nil)
}
@Test func liveProgressMissingRequiredKeyDecodesToNil() {
var dict = WCPayload.encodeLiveProgress(liveProgress(phaseEnd: nil))
dict[WCPayload.lpWorkoutIDKey] = nil // drop a required field
#expect(WCPayload.decodeLiveProgress(dict) == nil)
}
@Test func liveEndedCarriesTypeAndIDs() {
let dict = WCPayload.encodeLiveEnded(workoutID: "01WKLIVE", logID: "LOG-LIVE")
#expect(dict[WCPayload.typeKey] as? String == WCPayload.liveEndedType)
#expect(dict[WCPayload.lpWorkoutIDKey] as? String == "01WKLIVE")
#expect(dict[WCPayload.lpLogIDKey] as? String == "LOG-LIVE")
}
}
@@ -0,0 +1,195 @@
import Foundation
import SwiftData
import Testing
import IndieSync
@testable import Workouts
/// Mirrors `SeedLibraryTests.mapperRoundTripPreservesPristineness` for the *workout*
/// aggregate: a `WorkoutDocument` pushed through `CacheMapper.upsertWorkout` (the
/// document cache direction the metadata observer / watch bridge use) and rebuilt
/// via `WorkoutDocument(from: entity)` (the cache document write path) must
/// preserve every field in both directions the aggregate's own columns, the full
/// embedded log list (order, machine settings, per-log timestamps), and the health
/// metrics. A field that survives one direction but not the other silently forks or
/// drops user data on the very first cache rebuild.
struct WorkoutDocumentMapperTests {
// Whole-second epochs kept distinct per field so a swapped assignment can't hide.
private static let start = Date(timeIntervalSince1970: 1_700_000_000)
private static let end = Date(timeIntervalSince1970: 1_700_003_600)
private static let created = Date(timeIntervalSince1970: 1_699_999_000)
private static let updated = Date(timeIntervalSince1970: 1_700_004_000)
private static let logDate = Date(timeIntervalSince1970: 1_700_000_500)
private static let startedAt = Date(timeIntervalSince1970: 1_700_000_600)
private static let completedAt = Date(timeIntervalSince1970: 1_700_001_200)
private static let recordedAt = Date(timeIntervalSince1970: 1_700_003_700)
@MainActor
private func makeContext() throws -> ModelContext {
let schema = Schema([Split.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]))
}
private func log(
id: String, name: String, order: Int, status: WorkoutStatus,
machineSettings: [MachineSetting]? = nil, startedAt: Date? = nil, completedAt: Date? = nil
) -> WorkoutLogDocument {
WorkoutLogDocument(
id: id, exerciseName: name, order: order, sets: 4, reps: 10, weight: 135,
loadType: LoadType.weight.rawValue, durationSeconds: 0, currentStateIndex: 2,
status: status.rawValue, notes: "note-\(name)", date: Self.logDate,
machineSettings: machineSettings, startedAt: startedAt, completedAt: completedAt
)
}
private func fullMetrics() -> WorkoutMetrics {
WorkoutMetrics(
activeEnergyKcal: 321.5, avgHeartRate: 128, maxHeartRate: 165, minHeartRate: 71,
totalVolume: 5400, hrZoneSeconds: [10, 20, 30, 40, 50],
healthKitWorkoutUUID: "HK-UUID-123", source: .watch, recordedAt: Self.recordedAt
)
}
/// A fully-populated workout (three logs exercising the nil / empty / populated
/// machineSettings distinction, distinct per-log timestamps, full metrics) must
/// round-trip byte-for-byte equal in both directions.
@MainActor
@Test func roundTripPreservesEveryField() throws {
let context = try makeContext()
let original = WorkoutDocument(
schemaVersion: WorkoutDocument.currentSchemaVersion,
id: "01WORKOUTROUNDTRIP00000001", splitID: "SPLIT-42", splitName: "Push Day",
start: Self.start, end: Self.end, status: WorkoutStatus.completed.rawValue,
createdAt: Self.created, updatedAt: Self.updated,
logs: [
// order 0: not a machine exercise (nil), never started.
log(id: "LOG-A", name: "Bench Press", order: 0, status: .notStarted, machineSettings: nil),
// order 1: machine exercise, nothing recorded yet (empty nil), completed with timestamps.
log(id: "LOG-B", name: "Leg Press", order: 1, status: .completed, machineSettings: [],
startedAt: Self.startedAt, completedAt: Self.completedAt),
// order 2: machine exercise with recorded settings, skipped.
log(id: "LOG-C", name: "Chest Press", order: 2, status: .skipped,
machineSettings: [MachineSetting(name: "Seat Height", value: "4"),
MachineSetting(name: "Back Rest", value: "45°")],
startedAt: Self.startedAt),
],
metrics: fullMetrics()
)
CacheMapper.upsertWorkout(original, relativePath: original.relativePath, into: context)
let entity = try #require(CacheMapper.fetchWorkout(id: original.id, in: context))
let rebuilt = WorkoutDocument(from: entity)
#expect(rebuilt == original)
// 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.start == Self.start)
#expect(rebuilt.end == Self.end)
#expect(rebuilt.createdAt == Self.created)
#expect(rebuilt.updatedAt == Self.updated)
#expect(rebuilt.status == WorkoutStatus.completed.rawValue)
#expect(rebuilt.logs.count == 3)
#expect(rebuilt.logs[0].machineSettings == nil) // not a machine exercise
#expect(rebuilt.logs[1].machineSettings == []) // machine, nothing recorded
#expect(rebuilt.logs[1].startedAt == Self.startedAt)
#expect(rebuilt.logs[1].completedAt == Self.completedAt)
#expect(rebuilt.logs[2].machineSettings?.count == 2)
#expect(rebuilt.metrics == fullMetrics())
#expect(rebuilt.metrics?.hrZoneSeconds == [10, 20, 30, 40, 50])
#expect(rebuilt.metrics?.healthKitWorkoutUUID == "HK-UUID-123")
}
/// A workout with no metrics yet (the common in-progress / just-started case) must
/// round-trip to `nil` metrics, not a phantom zero-filled `WorkoutMetrics`.
@MainActor
@Test func roundTripPreservesNilMetrics() throws {
let context = try makeContext()
let original = WorkoutDocument(
schemaVersion: WorkoutDocument.currentSchemaVersion,
id: "01WORKOUTNOMETRICS0000001", splitID: nil, splitName: 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)],
metrics: nil
)
CacheMapper.upsertWorkout(original, relativePath: original.relativePath, into: context)
let entity = try #require(CacheMapper.fetchWorkout(id: original.id, in: context))
let rebuilt = WorkoutDocument(from: entity)
#expect(rebuilt == original)
#expect(rebuilt.metrics == nil)
#expect(rebuilt.splitID == nil)
#expect(rebuilt.end == nil)
}
/// The embedded logs are reconciled by id and rebuilt via `logsArray` (sorted by
/// `order`), so a document whose logs arrive out of order comes back canonically
/// ordered the round-trip normalizes, never scrambles.
@MainActor
@Test func roundTripNormalizesLogOrder() throws {
let context = try makeContext()
let scrambled = WorkoutDocument(
schemaVersion: WorkoutDocument.currentSchemaVersion,
id: "01WORKOUTLOGORDER00000001", splitID: "S", splitName: "S",
start: Self.start, end: Self.end, status: WorkoutStatus.completed.rawValue,
createdAt: Self.created, updatedAt: Self.updated,
logs: [
log(id: "LOG-2", name: "Third", order: 2, status: .completed),
log(id: "LOG-0", name: "First", order: 0, status: .completed),
log(id: "LOG-1", name: "Second", order: 1, status: .completed),
],
metrics: nil
)
CacheMapper.upsertWorkout(scrambled, relativePath: scrambled.relativePath, into: context)
let entity = try #require(CacheMapper.fetchWorkout(id: scrambled.id, in: context))
let rebuilt = WorkoutDocument(from: entity)
#expect(rebuilt.logs.map(\.order) == [0, 1, 2])
#expect(rebuilt.logs.map(\.id) == ["LOG-0", "LOG-1", "LOG-2"])
#expect(rebuilt.logs.map(\.exerciseName) == ["First", "Second", "Third"])
}
/// Re-upserting a changed document over an existing cache entity reconciles the log
/// set by id: a removed log is dropped, a new one inserted, survivors updated in
/// place matching the observer's frequent in-workout re-imports.
@MainActor
@Test func reUpsertReconcilesLogsByID() throws {
let context = try makeContext()
let first = WorkoutDocument(
schemaVersion: WorkoutDocument.currentSchemaVersion,
id: "01WORKOUTREUPSERT00000001", splitID: "S", splitName: "S",
start: Self.start, end: nil, status: WorkoutStatus.inProgress.rawValue,
createdAt: Self.created, updatedAt: Self.updated,
logs: [
log(id: "LOG-keep", name: "Kept", order: 0, status: .completed),
log(id: "LOG-drop", name: "Dropped", order: 1, status: .notStarted),
],
metrics: nil
)
CacheMapper.upsertWorkout(first, relativePath: first.relativePath, into: context)
try context.save()
var second = first
second.logs = [
log(id: "LOG-keep", name: "Kept Renamed", order: 0, status: .completed), // updated in place
log(id: "LOG-add", name: "Added", order: 1, status: .notStarted), // inserted
]
CacheMapper.upsertWorkout(second, relativePath: second.relativePath, into: context)
// The observer saves after applying each delta batch; the relationship array only
// reflects a child deletion once saved, so mirror that here.
try context.save()
let entity = try #require(CacheMapper.fetchWorkout(id: second.id, in: context))
let rebuilt = WorkoutDocument(from: entity)
#expect(rebuilt.logs.map(\.id) == ["LOG-keep", "LOG-add"])
#expect(rebuilt.logs.first?.exerciseName == "Kept Renamed")
#expect(!rebuilt.logs.contains { $0.id == "LOG-drop" })
}
}
@@ -0,0 +1,86 @@
import Foundation
import Testing
import IndieSync
@testable import Workouts
/// Pins `WorkoutDocument.relativePath` the `Workouts/YYYY/MM/<ULID>.json` month
/// bucketing that decides where every workout file lives. The bucket derives from
/// `start` through a gregorian `Calendar`, which uses `TimeZone.current`, so the
/// tests build their dates from explicit components in that same calendar and stay
/// mid-month/mid-day (day 15, noon) so no real-world UTC offset (±14 h) can shift the
/// month making every assertion time-zone-independent. Also documents the orphan
/// condition FIX-1 addresses in `SyncEngine.save(workout:)`: editing `start` across a
/// month boundary re-buckets the SAME id to a new path.
struct WorkoutPathBucketingTests {
/// A date fixed to the 15th at noon in the gregorian calendar's current time zone
/// far enough from any month edge that the UTC-offset ambiguity can't move the month
/// (matches `DuplicateCleanupPlannerTests.fixedDate`). Decomposed by `relativePath`'s
/// own `Calendar(identifier: .gregorian)`, the year/month come back stable.
private func date(year: Int, month: Int, day: Int = 15, hour: Int = 12) -> Date {
var comps = DateComponents()
comps.year = year
comps.month = month
comps.day = day
comps.hour = hour
return Calendar(identifier: .gregorian).date(from: comps)!
}
@Test func bucketsByYearAndMonth() {
let path = WorkoutDocument.relativePath(id: "01ABC", start: date(year: 2024, month: 3))
#expect(path == "Workouts/2024/03/01ABC.json")
}
/// The month is zero-padded to two digits so lexical sort matches chronological sort.
@Test func padsSingleDigitMonth() {
#expect(WorkoutDocument.relativePath(id: "X", start: date(year: 2025, month: 1))
== "Workouts/2025/01/X.json")
#expect(WorkoutDocument.relativePath(id: "X", start: date(year: 2025, month: 9))
== "Workouts/2025/09/X.json")
}
/// December January crosses the year, not just the month bucket.
@Test func crossesYearBoundary() {
#expect(WorkoutDocument.relativePath(id: "DEC", start: date(year: 2024, month: 12))
== "Workouts/2024/12/DEC.json")
#expect(WorkoutDocument.relativePath(id: "JAN", start: date(year: 2025, month: 1))
== "Workouts/2025/01/JAN.json")
}
/// The instance `relativePath` property and the static computation agree the
/// 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,
status: WorkoutStatus.notStarted.rawValue, createdAt: date(year: 2024, month: 7),
updatedAt: date(year: 2024, month: 7), logs: [], metrics: nil
)
#expect(doc.relativePath == "Workouts/2024/07/01WK.json")
#expect(doc.relativePath == WorkoutDocument.relativePath(id: doc.id, start: doc.start))
}
/// FIX-1 (rebucketing orphan) path-computation level. `SyncEngine.save(workout:)`
/// needs a live iCloud container to exercise directly, so this pins the invariant it
/// relies on: the SAME workout id, saved after its `start` moves to a different month,
/// resolves to a DIFFERENT path. The old file at the previous bucket is therefore an
/// orphan the engine must now remove (same id lives on at the new path plain
/// removal, no tombstone). If these two paths were ever equal the fix would be a
/// no-op; this test guarantees they diverge exactly when the month changes.
@Test func editingStartMonthRebucketsSameID() {
let id = "01WORKOUTREBUCKET00000001"
let marchStart = date(year: 2024, month: 3)
let aprilStart = date(year: 2024, month: 4)
let oldPath = WorkoutDocument.relativePath(id: id, start: marchStart)
let newPath = WorkoutDocument.relativePath(id: id, start: aprilStart)
#expect(oldPath != newPath) // the orphan-producing divergence
#expect(oldPath == "Workouts/2024/03/\(id).json")
#expect(newPath == "Workouts/2024/04/\(id).json")
// Same-month edits (e.g. bumping the day/hour) keep the path stable no orphan,
// no removal.
let sameMonthLater = date(year: 2024, month: 3, day: 28, hour: 6)
#expect(WorkoutDocument.relativePath(id: id, start: sameMonthLater) == oldPath)
}
}
@@ -0,0 +1,179 @@
import Foundation
import Testing
import IndieSync
@testable import Workouts
/// Locks the workout status state machine on `WorkoutDocument` the single
/// source of the status-from-logs rule every screen funnels through
/// (`recomputeStatusFromLogs`, `endKeepingProgress`) plus the per-log
/// `WorkoutLogDocument.transition(to:)` timestamp bookkeeping. Includes the
/// regression for the "end re-stamped on every edit" bug: recomputing an
/// already-completed workout must keep its original `end`, not jump it to now.
struct WorkoutStatusMachineTests {
private static let start = Date(timeIntervalSince1970: 1_700_000_000)
private static let originalEnd = Date(timeIntervalSince1970: 1_700_003_600)
private func log(id: String, order: Int, status: WorkoutStatus) -> WorkoutLogDocument {
WorkoutLogDocument(
id: id, exerciseName: "Ex-\(id)", order: order, sets: 3, reps: 10, weight: 100,
loadType: LoadType.weight.rawValue, durationSeconds: 0, currentStateIndex: 0,
status: status.rawValue, notes: nil, date: Self.start
)
}
private func workout(
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,
createdAt: Self.start, updatedAt: Self.start, logs: logs, metrics: nil
)
}
// MARK: - recomputeStatusFromLogs
/// All logs resolved (a mix of completed and skipped) the workout is completed and
/// gets an `end` stamped on the transition.
@Test func allResolvedCompletesAndStampsEnd() {
var w = workout(status: .inProgress, end: nil, logs: [
log(id: "a", order: 0, status: .completed),
log(id: "b", order: 1, status: .skipped),
log(id: "c", order: 2, status: .completed),
])
w.recomputeStatusFromLogs()
#expect(w.status == WorkoutStatus.completed.rawValue)
#expect(w.end != nil) // stamped on the transition into completed
}
/// Any log started but not all resolved in progress, `end` cleared.
@Test func anyStartedIsInProgressWithNoEnd() {
var w = workout(status: .completed, end: Self.originalEnd, logs: [
log(id: "a", order: 0, status: .completed),
log(id: "b", order: 1, status: .inProgress),
log(id: "c", order: 2, status: .notStarted),
])
w.recomputeStatusFromLogs()
#expect(w.status == WorkoutStatus.inProgress.rawValue)
#expect(w.end == nil) // reopened a stale end must clear
}
/// No log started not started, `end` cleared.
@Test func noneStartedIsNotStarted() {
var w = workout(status: .inProgress, end: Self.originalEnd, logs: [
log(id: "a", order: 0, status: .notStarted),
log(id: "b", order: 1, status: .notStarted),
])
w.recomputeStatusFromLogs()
#expect(w.status == WorkoutStatus.notStarted.rawValue)
#expect(w.end == nil)
}
/// A workout with no logs is not "all resolved" (the guard excludes the empty set)
/// not started.
@Test func emptyLogsIsNotStarted() {
var w = workout(status: .inProgress, end: Self.originalEnd, logs: [])
w.recomputeStatusFromLogs()
#expect(w.status == WorkoutStatus.notStarted.rawValue)
#expect(w.end == nil)
}
/// REGRESSION (FIX-3): an already-completed workout that still has all logs resolved
/// keeps its ORIGINAL `end` when recompute runs again (e.g. the user re-opens the
/// finished workout to fix a typo). The old code re-stamped `end = Date()` every
/// time, jerking a months-old workout's end forward to now.
@Test func alreadyCompletedKeepsOriginalEnd() {
var w = workout(status: .completed, end: Self.originalEnd, logs: [
log(id: "a", order: 0, status: .completed),
log(id: "b", order: 1, status: .skipped),
])
w.recomputeStatusFromLogs()
#expect(w.status == WorkoutStatus.completed.rawValue)
#expect(w.end == Self.originalEnd) // preserved, not re-stamped to now
}
/// A workout marked completed but somehow missing an `end` (defensive: a legacy or
/// partially-written file) gets one stamped the preservation only applies when a
/// real end already exists.
@Test func completedWithNilEndGetsStamped() {
var w = workout(status: .completed, end: nil, logs: [
log(id: "a", order: 0, status: .completed),
])
w.recomputeStatusFromLogs()
#expect(w.status == WorkoutStatus.completed.rawValue)
#expect(w.end != nil)
}
// MARK: - endKeepingProgress
/// Ending a run in progress skips every not-completed log, keeps completed ones, and
/// resolves the workout to completed with an `end` and a bumped `updatedAt`.
@Test func endKeepingProgressSkipsRemainderAndCompletes() {
var w = workout(status: .inProgress, end: nil, logs: [
log(id: "a", order: 0, status: .completed),
log(id: "b", order: 1, status: .inProgress),
log(id: "c", order: 2, status: .notStarted),
])
w.endKeepingProgress()
#expect(w.status == WorkoutStatus.completed.rawValue)
#expect(w.end != nil)
#expect(w.logs[0].status == WorkoutStatus.completed.rawValue) // kept
#expect(w.logs[1].status == WorkoutStatus.skipped.rawValue) // was in progress
#expect(w.logs[2].status == WorkoutStatus.skipped.rawValue) // was not started
#expect(w.updatedAt > Self.start)
}
// MARK: - WorkoutLogDocument.transition
@Test func transitionToInProgressStampsStartOnce() {
var l = log(id: "a", order: 0, status: .notStarted)
l.transition(to: .inProgress)
let firstStart = l.startedAt
#expect(firstStart != nil)
#expect(l.completedAt == nil)
// Re-entering inProgress must not overwrite the original startedAt.
l.transition(to: .inProgress)
#expect(l.startedAt == firstStart)
}
@Test func transitionToCompletedStampsCompletionOnceAndKeepsStart() {
var l = log(id: "a", order: 0, status: .notStarted)
l.transition(to: .inProgress)
let started = l.startedAt
l.transition(to: .completed)
let firstCompleted = l.completedAt
#expect(l.status == WorkoutStatus.completed.rawValue)
#expect(firstCompleted != nil)
#expect(l.startedAt == started) // start preserved through completion
// A second completed transition must not move completedAt.
l.transition(to: .completed)
#expect(l.completedAt == firstCompleted)
}
@Test func transitionToNotStartedClearsBothTimestamps() {
var l = log(id: "a", order: 0, status: .completed)
l.transition(to: .inProgress)
l.transition(to: .completed)
l.transition(to: .notStarted)
#expect(l.status == WorkoutStatus.notStarted.rawValue)
#expect(l.startedAt == nil)
#expect(l.completedAt == nil)
}
/// Skipping clears any completion stamp but leaves `startedAt` (the exercise may
/// have been partway through before the user skipped the rest).
@Test func transitionToSkippedClearsCompletionKeepsStart() {
var l = log(id: "a", order: 0, status: .notStarted)
l.transition(to: .inProgress)
l.transition(to: .completed)
let started = l.startedAt
l.transition(to: .skipped)
#expect(l.status == WorkoutStatus.skipped.rawValue)
#expect(l.completedAt == nil)
#expect(l.startedAt == started)
}
}