diff --git a/CLAUDE.md b/CLAUDE.md index d643081..8ff7af9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -69,8 +69,9 @@ All in `Shared/Model/`: ### Starter Data (deterministic seeds) - **Starter splits**: shipped as byte-canonical `SplitDocument` JSON in `Workouts/Resources/StarterSplits/*.split.json` with **fixed ULIDs** (shared `01DXF6DT00` prefix, frozen 2020 timestamp) and fixed content, regenerated only by `Scripts/generate_starter_splits.swift`. `SeedLibrary` (`Workouts/Seed/`) loads the catalog; **seeds are immutable** — `SyncEngine.save(split:)` transparently clones an edited seed to a fresh ULID and soft-deletes the seed, whose stub is exempt from pruning and vetoes resurrection forever (open views follow the identity swap via `sync.currentSplitID(for:)`). -- **Auto-seed**: `SyncEngine.autoSeedIfEmpty()` writes the verbatim bundle bytes after connect, only into a verifiably empty container (no data files, no stubs) re-checked after a settle delay — wrong guesses are harmless because identical bytes make same-path conflicts empty and stubs reap resurrected seeds. The on-demand path (`SplitSeeder.seedDefaults`, "Add Starter Splits") restores deleted seeds (lifting the veto stub) or writes missing ones, skipping live names. -- **Exercise library**: authored in `Exercise Library/` at the repo root (per-exercise `info.md`, SVG visuals, motion rigs, Python render pipeline); the app bundles the exported `Workouts/Resources/ExerciseMotions/*.motion.json` rigs, which double as the exercise picker's list (`ExerciseMotionLibrary.exerciseNames`). +- **Auto-seed & reconcile**: after connect + reconcile, `SyncEngine.seedOrReconcile()` (deferred, settle-delayed) branches on container state so the two seeders can never both fire. An empty container → `autoSeedIfEmpty()` writes the verbatim bundle bytes (re-checked after the settle delay). A non-empty container → `reconcileSeeds()` diffs each bundled seed against its fixed-ULID file via the pure `SeedReconcilePlanner` (`Workouts/Sync/SeedReconcile.swift`): a stale revision is overwritten with current bundle bytes (a **semantic** compare with `schemaVersion` normalized — never a byte compare, since older files carry now-removed keys), a newly-shipped seed with no file and no stub is written (unless a live same-name split already exists, or a newer app version wrote the file), and a deleted seed (stub present) is left vetoed. Wrong guesses are harmless — identical bytes make same-path conflicts empty, stubs reap resurrected seeds, and the veto + name guards are re-checked right before each write. This is safe because a fixed-ULID file can never hold user content (every edit forks to a fresh ULID). +- **Restore Starter Splits** (Settings) is the one deliberate veto lift: `SyncEngine.restoreSeeds()` removes a deleted seed's stub and rewrites the **current bundle bytes** (never the stub's old contents), still skipping seeds whose live file already exists (reconcile handles upgrades) or whose name collides with a live split; it returns the count for a brief in-Settings confirmation. +- **Exercise library**: authored in `Exercise Library/` at the repo root (per-exercise `info.md`, SVG visuals, motion rigs, Python render pipeline); the app bundles the exported `Workouts/Resources/ExerciseMotions/*` resources — `*.motion.json` rigs, which double as the exercise picker's list (`ExerciseMotionLibrary.exerciseNames`), and `*.info.md` reference pages, parsed by `ExerciseInfo` and rendered in the Settings → Library → Exercises detail screen. Re-export both with `python3 render.py --export`. ## Guidelines diff --git a/Workouts/HealthKit/WorkoutHealthWriter.swift b/Workouts/HealthKit/WorkoutHealthWriter.swift index 3f1c3af..3f94aad 100644 --- a/Workouts/HealthKit/WorkoutHealthWriter.swift +++ b/Workouts/HealthKit/WorkoutHealthWriter.swift @@ -172,9 +172,15 @@ final class WorkoutHealthWriter { } } + /// The split's configured activity type, following the seed clone-on-edit + /// redirect (the estimate can run seconds to hours after the workout, so the + /// split may have forked in between). Generic strength when the split is gone. private func activityType(for doc: WorkoutDocument) -> WorkoutActivityType { - if let splitID = doc.splitID, let split = CacheMapper.fetchSplit(id: splitID, in: context) { - return split.activityTypeEnum + if let splitID = doc.splitID { + let resolved = syncEngine?.currentSplitID(for: splitID) ?? splitID + if let split = CacheMapper.fetchSplit(id: resolved, in: context) { + return split.activityTypeEnum + } } return .traditionalStrength } diff --git a/Workouts/Seed/SplitSeeder.swift b/Workouts/Seed/SplitSeeder.swift deleted file mode 100644 index 4462225..0000000 --- a/Workouts/Seed/SplitSeeder.swift +++ /dev/null @@ -1,32 +0,0 @@ -import Foundation -import SwiftData - -/// The bundled immutable starter-split library, brought back on demand. The seeds -/// themselves — fixed ULIDs, byte-canonical bundle JSON — live in `SeedLibrary`; -/// editing one clones it and permanently tombstones the seed (see -/// `SyncEngine.save(split:)`). The true first-run case is handled automatically by -/// `SyncEngine.autoSeedIfEmpty`; this on-demand path (the "Add Starter Splits" -/// button) is for a user who wants the starters back after removing some. -enum SplitSeeder { - /// Add every starter split the user doesn't already have. For each seed: - /// • skip if a live split with the same NAME exists — a user's edited clone of - /// "Upper Body" must not be joined by a resurrected seed of the same name; - /// • else if the seed was deleted (tombstoned), restore it (lifting the veto); - /// • else write it fresh. - /// Seed `order` values are fixed (0–3); a collision with a user split's order is - /// an accepted cosmetic tie. Idempotent against double-taps and partial prior seeds. - @MainActor - static func seedDefaults(into context: ModelContext, using sync: SyncEngine) async { - let existing = (try? context.fetch(FetchDescriptor())) ?? [] - let existingNames = Set(existing.map(\.name)) - - for seed in SeedLibrary.seeds { - if existingNames.contains(seed.doc.name) { continue } - if await sync.isTombstoned(id: seed.id) { - await sync.restoreSeed(seed) - } else { - await sync.writeSeed(seed) - } - } - } -} diff --git a/Workouts/Sync/DuplicateCleanup.swift b/Workouts/Sync/DuplicateCleanup.swift new file mode 100644 index 0000000..8783ff0 --- /dev/null +++ b/Workouts/Sync/DuplicateCleanup.swift @@ -0,0 +1,235 @@ +import Foundation + +// Pure planning logic for the developer-facing "duplicate cleanup" tool. No I/O +// and no `SyncEngine` dependency, so it's fully unit-testable in isolation: +// `SyncEngine.scanForDuplicates()` gathers the already-decoded documents and the +// referenced-split-id set, hands them to `DuplicateCleanupPlanner.plan(...)`, and +// `SyncEngine.performCleanup(_:)` executes the resulting plan file by file. +// +// Duplicates are detected by a *content* fingerprint that deliberately ignores +// identity (ids), timestamps, and cosmetic fields — two splits/workouts that were +// created independently (e.g. by a sync hiccup, a restore, or manual testing) but +// carry the same real content are duplicates even though every id differs. + +// MARK: - Plan + +struct DuplicateCleanupPlan: Sendable { + struct SplitGroup: Sendable, Identifiable { + /// Members that survive — always non-empty when the group is emitted. + var keep: [SplitDocument] + /// Members slated for deletion — always non-empty when the group is emitted. + var delete: [SplitDocument] + + var id: String { + keep.isEmpty ? (delete.first?.id ?? "") : keep.map(\.id).joined(separator: ",") + } + } + + struct WorkoutGroup: Sendable, Identifiable { + var keep: WorkoutDocument + var delete: [WorkoutDocument] + + var id: String { keep.id } + } + + var splitGroups: [SplitGroup] + var workoutGroups: [WorkoutGroup] + + var isEmpty: Bool { splitGroups.isEmpty && workoutGroups.isEmpty } + + /// Total number of documents this plan would delete across every group. + var deleteCount: Int { + splitGroups.reduce(0) { $0 + $1.delete.count } + workoutGroups.reduce(0) { $0 + $1.delete.count } + } +} + +// MARK: - Planner + +enum DuplicateCleanupPlanner { + /// Builds a cleanup plan from already-decoded, already-`isReadable`-filtered + /// documents. `referencedSplitIDs` must include every split id any workout + /// (readable or quarantined) points at, plus any clone-redirect targets — the + /// caller (`SyncEngine.scanForDuplicates`) is responsible for assembling it. + static func plan( + splits: [SplitDocument], + workouts: [WorkoutDocument], + referencedSplitIDs: Set, + isSeed: (String) -> Bool + ) -> DuplicateCleanupPlan { + DuplicateCleanupPlan( + splitGroups: planSplits(splits, referencedSplitIDs: referencedSplitIDs, isSeed: isSeed), + workoutGroups: planWorkouts(workouts) + ) + } + + // MARK: Splits + + /// Hashable projection of a `MachineSetting`. The fingerprints keep the + /// nil / empty distinction (non-machine vs. machine-with-nothing-recorded) + /// and the user-defined order, both of which are real content. + private struct MachineSettingFingerprint: Hashable { + var name: String + var value: String + } + + private static func fingerprint(_ settings: [MachineSetting]?) -> [MachineSettingFingerprint]? { + settings.map { $0.map { MachineSettingFingerprint(name: $0.name, value: $0.value) } } + } + + /// Content-only projection of an exercise: ignores id and order (order is + /// captured by the parent's sort, not the element itself). + private struct ExerciseFingerprint: Hashable { + var name: String + var sets: Int + var reps: Int + var weight: Int + var loadType: Int + var durationSeconds: Int + var machineSettings: [MachineSettingFingerprint]? + } + + /// Content-only projection of a split: ignores id, dates, color, systemImage, + /// order, and activityType. + private struct SplitFingerprint: Hashable { + var name: String + var exercises: [ExerciseFingerprint] + } + + private static func fingerprint(_ doc: SplitDocument) -> SplitFingerprint { + SplitFingerprint( + name: doc.name.trimmingCharacters(in: .whitespacesAndNewlines), + exercises: doc.exercises.sorted { $0.order < $1.order }.map { + ExerciseFingerprint( + name: $0.name, sets: $0.sets, reps: $0.reps, weight: $0.weight, + loadType: $0.loadType, durationSeconds: $0.durationSeconds, + machineSettings: fingerprint($0.machineSettings) + ) + } + ) + } + + /// Survivor rules (safety-critical — see file header): + /// 1. A member referenced by any workout is always kept. + /// 2. A member that's a bundled seed is always kept. + /// 3. If neither rule protects anyone, the lexicographically smallest id + /// (ULIDs sort chronologically) survives — deterministic across devices. + /// Everyone else in the group is deleted. A group with nothing to delete + /// (e.g. every member is protected) is dropped entirely. + private static func planSplits( + _ splits: [SplitDocument], + referencedSplitIDs: Set, + isSeed: (String) -> Bool + ) -> [DuplicateCleanupPlan.SplitGroup] { + let grouped = Dictionary(grouping: splits, by: fingerprint) + var groups: [DuplicateCleanupPlan.SplitGroup] = [] + + for (_, members) in grouped where members.count > 1 { + var keep: [SplitDocument] = [] + var candidates: [SplitDocument] = [] + for member in members { + if referencedSplitIDs.contains(member.id) || isSeed(member.id) { + keep.append(member) + } else { + candidates.append(member) + } + } + if keep.isEmpty, let survivorID = candidates.map(\.id).min() { + keep = candidates.filter { $0.id == survivorID } + candidates.removeAll { $0.id == survivorID } + } + guard !candidates.isEmpty else { continue } + groups.append(DuplicateCleanupPlan.SplitGroup( + keep: keep.sorted { $0.id < $1.id }, + delete: candidates.sorted { $0.id < $1.id } + )) + } + + return groups.sorted { $0.id < $1.id } + } + + // MARK: Workouts + + /// Content-only projection of a log: ignores id and the started/completed + /// timestamps. + private struct WorkoutLogFingerprint: Hashable { + var exerciseName: String + var order: Int + var sets: Int + var reps: Int + var weight: Int + var loadType: Int + var durationSeconds: Int + var status: String + var currentStateIndex: Int + var notes: String + var machineSettings: [MachineSettingFingerprint]? + } + + /// Content-only projection of a workout: ignores id, createdAt/updatedAt, + /// startedAt/completedAt, exact start/end time (only the calendar day of + /// `start` matters), and metrics. + private struct WorkoutFingerprint: Hashable { + var splitID: String + var splitName: String + var year: Int + var month: Int + var day: Int + var status: String + var logs: [WorkoutLogFingerprint] + } + + private static func fingerprint(_ doc: WorkoutDocument) -> WorkoutFingerprint { + let comps = Calendar.current.dateComponents([.year, .month, .day], from: doc.start) + return WorkoutFingerprint( + splitID: doc.splitID ?? "", + splitName: doc.splitName ?? "", + year: comps.year ?? 0, + month: comps.month ?? 0, + day: comps.day ?? 0, + status: doc.status, + logs: doc.logs.sorted { $0.order < $1.order }.map { + WorkoutLogFingerprint( + exerciseName: $0.exerciseName, order: $0.order, sets: $0.sets, reps: $0.reps, + weight: $0.weight, loadType: $0.loadType, durationSeconds: $0.durationSeconds, + status: $0.status, currentStateIndex: $0.currentStateIndex, + notes: $0.notes ?? "", + machineSettings: fingerprint($0.machineSettings) + ) + } + ) + } + + /// Ranks a workout for survivorship — lower sorts first (wins): (1) holds the + /// HealthKit link (deleting that copy would cascade-delete the real Health + /// sample), (2) has metrics at all, (3) smallest id. + private static func survivorRank(_ doc: WorkoutDocument) -> (Int, Int, String) { + (doc.metrics?.healthKitWorkoutUUID != nil ? 0 : 1, + doc.metrics != nil ? 0 : 1, + doc.id) + } + + /// If any member of a duplicate group is in progress, the whole group is + /// dropped — never touch an active workout, since the watch matches its live + /// run by workout id. Otherwise the survivor is picked by `survivorRank` and + /// everyone else is deleted. + private static func planWorkouts(_ workouts: [WorkoutDocument]) -> [DuplicateCleanupPlan.WorkoutGroup] { + let grouped = Dictionary(grouping: workouts, by: fingerprint) + var groups: [DuplicateCleanupPlan.WorkoutGroup] = [] + + for (_, members) in grouped where members.count > 1 { + if members.contains(where: { $0.status == WorkoutStatus.inProgress.rawValue }) { continue } + + let ranked = members.sorted { a, b in + let ra = survivorRank(a), rb = survivorRank(b) + if ra.0 != rb.0 { return ra.0 < rb.0 } + if ra.1 != rb.1 { return ra.1 < rb.1 } + return ra.2 < rb.2 + } + guard let survivor = ranked.first else { continue } + let toDelete = ranked.dropFirst().sorted { $0.id < $1.id } + groups.append(DuplicateCleanupPlan.WorkoutGroup(keep: survivor, delete: Array(toDelete))) + } + + return groups.sorted { $0.id < $1.id } + } +} diff --git a/Workouts/Sync/SeedReconcile.swift b/Workouts/Sync/SeedReconcile.swift new file mode 100644 index 0000000..2a416fd --- /dev/null +++ b/Workouts/Sync/SeedReconcile.swift @@ -0,0 +1,114 @@ +import Foundation + +// Pure decision logic for bringing an install's on-disk starter-split library in +// line with the current app bundle. No I/O and no `SyncEngine` dependency, so the +// safety-critical part is fully unit-testable in isolation: `SyncEngine.reconcileSeeds()` +// gathers the live seed-path files, stub set, and live split names, hands each seed to +// `SeedReconcilePlanner.decision(...)`, and executes the returned skip / upgrade / write. +// +// Why this exists: starter seeds ship as byte-canonical `SplitDocument` JSON under +// FIXED ULIDs (see `SeedLibrary`). `autoSeedIfEmpty` only ever writes them into a +// verifiably empty container, so an existing install can never pick up an upgraded +// seed revision or a newly-shipped seed. This planner decides, per seed, whether the +// live fixed-ULID file is up to date, stale (needs the current bundle bytes), deleted +// (vetoed), or missing (write it). +// +// Safety invariant this relies on: a live file at a seed's fixed ULID can NEVER hold +// user-authored content — every user edit forks the seed to a fresh random ULID +// (clone-on-edit), and fixed-ULID files only exist on app versions that have +// clone-on-edit. So overwriting a seed-path file with current bundle bytes never +// destroys user data; at worst it upgrades an older seed revision. + +// MARK: - Input + +/// Everything the planner needs to decide one seed, pre-gathered by the engine. +struct SeedReconcileInput: Equatable { + /// The seed's fixed ULID. + let seedID: String + /// The bundle seed document (canonical current content). + let seedDoc: SplitDocument + /// The decoded live file currently sitting at the seed's fixed path, or `nil` + /// when no live file exists there (deleted, or never written). + let liveDoc: SplitDocument? + /// Whether a tombstone stub exists for this seed's id. + let hasStub: Bool +} + +// MARK: - Decision + +enum SeedReconcileDecision: Equatable { + /// Do nothing — the reason is carried for logging and tests. + case skip(SeedSkipReason) + /// A live file exists at the seed path but differs from the bundle: overwrite it + /// with the verbatim bundle bytes (an older seed revision). + case upgrade + /// No live file, no veto, no name collision: write the verbatim bundle bytes (a + /// newly-shipped seed, or a pre-fixed-ULID install missing the file). + case write +} + +enum SeedSkipReason: Equatable { + /// The live file already matches the bundle seed (schemaVersion-normalized). + case upToDate + /// The live file was written by a newer app version — never downgrade-rewrite it. + case quarantined + /// No live file, but a stub exists: the user deleted this seed. Respect the veto. + case vetoed + /// No live file and no stub, but a live split already uses this seed's name + /// (e.g. a legacy runtime-built or user-cloned "Upper Body"). Writing the seed + /// would duplicate it by name. + case nameCollision +} + +// MARK: - Planner + +enum SeedReconcilePlanner { + /// The automatic on-connect decision for one seed (see the decision table in the + /// file header). `liveSplitNames` must be the names of every live split currently + /// in the container/cache after reconcile — the name-collision guard consults it. + static func decision( + for input: SeedReconcileInput, + liveSplitNames: Set + ) -> SeedReconcileDecision { + if let live = input.liveDoc { + // A live file sits at the seed path. Never touch one a newer app version + // wrote (the forward-compat quarantine), otherwise compare content. + guard live.isReadable else { return .skip(.quarantined) } + return semanticallyEqual(live: live, seed: input.seedDoc) + ? .skip(.upToDate) + : .upgrade + } + // No live file at the seed path. + if input.hasStub { return .skip(.vetoed) } + if liveSplitNames.contains(input.seedDoc.name) { return .skip(.nameCollision) } + return .write + } + + /// Semantic (not byte) equality of a live seed file against the bundle seed. + /// + /// A byte compare is wrong here: a container file may have been written by an + /// older app version, carrying extra now-removed keys and a lower `schemaVersion`. + /// Decoding both into the current `SplitDocument` already drops unknown keys, so + /// the only remaining artificial difference is the version stamp — normalize the + /// live doc's `schemaVersion` to the seed's before the `==` so an older wrapper of + /// identical content reads as up to date (no churn), while a genuine content + /// change reads as different (upgrade). + static func semanticallyEqual(live: SplitDocument, seed: SplitDocument) -> Bool { + var normalized = live + normalized.schemaVersion = seed.schemaVersion + return normalized == seed + } + + /// Whether the deliberate "Restore Starter Splits" action should (re)write a seed: + /// only when no live file sits at its path and no live split already uses its name. + /// Unlike `decision(...)` a stub does NOT veto here — restore lifts the veto — but + /// the engine still removes the stub as a separate execution step. A seed whose + /// live file already exists is left for `decision(...)` to upgrade. + static func shouldRestore( + hasLiveFile: Bool, + seedName: String, + liveSplitNames: Set + ) -> Bool { + !hasLiveFile && !liveSplitNames.contains(seedName) + } +} diff --git a/Workouts/Sync/SyncEngine.swift b/Workouts/Sync/SyncEngine.swift index 5df2887..92469b7 100644 --- a/Workouts/Sync/SyncEngine.swift +++ b/Workouts/Sync/SyncEngine.swift @@ -11,8 +11,12 @@ enum ICloudStatus: Equatable { } /// Orchestrates the iCloud Drive file layer and the SwiftData cache. iCloud is -/// the sole source of truth: every save/delete writes files only; the metadata -/// observer (and the connect-time reconcile) is the sole mutator of the cache. +/// the sole source of truth: every save/delete writes the file first, then +/// mirrors the change into the cache immediately (a same-process write doesn't +/// reliably wake the `NSMetadataQuery` observer — and never does in the +/// simulator — so waiting on it leaves the UI blind to the user's own action). +/// The observer and the connect-time reconcile re-apply idempotently and remain +/// the sole channel for *remote* changes. @Observable @MainActor final class SyncEngine { @@ -27,8 +31,9 @@ final class SyncEngine { /// Maps a seed's id to its clone's id after a clone-on-edit fork, so a view still /// holding the seed's id resolves to the live clone. In-memory only (@Observable - /// notifies on change); not persisted — a seed is edited at most once before it's - /// gone, so nothing needs to survive a relaunch. + /// notifies on change); not persisted — durability comes from `repointWorkouts`, + /// which rewrites `splitID` on the workout documents themselves at fork time. + /// This map only bridges views that captured the seed's id before the swap. private(set) var cloneRedirects: [String: String] = [:] /// Follow the redirect chain from `id` to the current live split id. A seed @@ -154,7 +159,7 @@ final class SyncEngine { startMonitoring(documentsURL: store.rootURL) cleanupOldStubs() // Off the connect path so opening the gate isn't delayed by the settle wait. - Task { await self.autoSeedIfEmpty() } + Task { await self.seedOrReconcile() } } /// Invoked from the connecting screen when the user chooses not to keep @@ -205,15 +210,8 @@ final class SyncEngine { onCacheChanged?() } - /// Apply a workout received from the watch. iCloud Drive stays the source of - /// truth (we write the file), but we also upsert the cache directly here. - /// - /// The phone's own edits drive a local view copy, so they don't need this — but a - /// watch-originated change has nothing else refreshing the phone UI, and a - /// same-process file overwrite doesn't reliably wake the `NSMetadataQuery` - /// observer. Upserting the doc we just wrote keeps cache and file consistent (the - /// observer re-applies idempotently if it does fire) and lets open phone screens - /// reflect watch progress live. + /// Apply a workout received from the watch — `save(workout:)` writes the file + /// and mirrors it into the cache, same as a local edit. func ingestFromWatch(_ doc: WorkoutDocument) async { // A workout deleted on the phone leaves a tombstone; a watch that missed the // delete may still push its stale copy. Honor the veto — never resurrect it — @@ -224,9 +222,6 @@ final class SyncEngine { return } await save(workout: doc) - CacheMapper.upsertWorkout(doc, relativePath: doc.relativePath, into: context) - do { try context.save() } catch { report("Cache save failed", error) } - onCacheChanged?() } // MARK: - Public CRUD (write path: files only) @@ -249,11 +244,12 @@ final class SyncEngine { do { try await store.write(doc, to: doc.relativePath) + CacheMapper.upsertSplit(doc, relativePath: doc.relativePath, into: context) + saveCacheAndNotify() lastSyncError = nil } catch { report("Failed to save split", error) } - // Cache updates reactively via the monitor. return doc.id } @@ -279,6 +275,7 @@ final class SyncEngine { try context.save() cloneRedirects[doc.id] = clone.id lastSyncError = nil + await repointWorkouts(from: doc.id, to: clone.id) onCacheChanged?() return clone.id } catch { @@ -287,10 +284,39 @@ final class SyncEngine { } } + /// Rewrite `splitID` on every workout that references `oldID`, so split lookups + /// (category grouping, add-exercise, plan mirroring, health estimates) keep + /// resolving after a seed fork — durably, across relaunches, and on the watch, + /// which the in-memory `cloneRedirects` map can't reach. `splitName` stays frozen + /// at what the workout was started as, matching rename semantics for regular + /// splits. Best effort per workout: a failed rewrite is reported and the redirect + /// map still covers it for this session. + private func repointWorkouts(from oldID: String, to newID: String) async { + guard let store else { return } + let referencing = (try? context.fetch( + FetchDescriptor(predicate: #Predicate { $0.splitID == oldID }) + )) ?? [] + guard !referencing.isEmpty else { return } + for workout in referencing { + var wDoc = WorkoutDocument(from: workout) + wDoc.splitID = newID + wDoc.updatedAt = Date() + do { + try await store.write(wDoc, to: wDoc.relativePath) + CacheMapper.upsertWorkout(wDoc, relativePath: wDoc.relativePath, into: context) + } catch { + report("Failed to repoint workout \(wDoc.id) at edited split", error) + } + } + do { try context.save() } catch { report("Cache save failed", error) } + } + func save(workout doc: WorkoutDocument) async { guard let store else { return } do { try await store.write(doc, to: doc.relativePath) + CacheMapper.upsertWorkout(doc, relativePath: doc.relativePath, into: context) + saveCacheAndNotify() lastSyncError = nil } catch { report("Failed to save workout", error) @@ -302,11 +328,24 @@ final class SyncEngine { } func delete(split: Split) async { - await softDelete(id: split.id, kind: "split", livePath: split.jsonRelativePath) + let id = split.id, livePath = split.jsonRelativePath + await softDelete(id: id, kind: "split", livePath: livePath) + deleteCachedEntity(id: id) + saveCacheAndNotify() } func delete(workout: Workout) async { - await softDelete(id: workout.id, kind: "workout", livePath: workout.jsonRelativePath) + let id = workout.id, livePath = workout.jsonRelativePath + await softDelete(id: id, kind: "workout", livePath: livePath) + deleteCachedEntity(id: id) + saveCacheAndNotify() + } + + /// Persist pending cache mutations and fan out the change notification — the + /// tail of every local write's immediate cache mirror. + private func saveCacheAndNotify() { + do { try context.save() } catch { report("Cache save failed", error) } + onCacheChanged?() } /// Writes a tombstone stub then removes the live file. Other devices learn of @@ -502,47 +541,266 @@ final class SyncEngine { onCacheChanged?() } - /// Add one seed fresh: write its verbatim bundle bytes and upsert the cache. The - /// on-demand "Add Starter Splits" path uses this for a seed that's neither present - /// nor tombstoned. - func writeSeed(_ seed: SeedLibrary.Seed) async { - guard let store else { return } - do { - try await store.writeData(seed.data, to: seed.doc.relativePath) - CacheMapper.upsertSplit(seed.doc, relativePath: seed.doc.relativePath, into: context) - try context.save() - lastSyncError = nil - } catch { - report("Failed to add starter split \(seed.doc.name)", error) + /// Post-connect seed maintenance, deferred off the connect path. Settles first — + /// an empty or sparse listing right after connect can just mean the metadata index + /// / placeholder files haven't surfaced existing data yet — then branches so the + /// two seeders can never both fire: an empty container is first-run auto-seed's + /// alone; a non-empty one is reconciled against the current bundle. + private func seedOrReconcile() async { + // Give existing files a chance to surface before deciding empty-vs-reconcile + // (the same caution `autoSeedIfEmpty` also takes internally). + try? await Task.sleep(for: .seconds(5)) + guard iCloudStatus == .available else { return } + if await containerEmpty() { + await autoSeedIfEmpty() + } else { + await reconcileSeeds() } - onCacheChanged?() } - /// Bring a previously-deleted seed back: lift its veto stub, then write its - /// verbatim bundle bytes. The bundle is the canonical restore source (seed content - /// is immutable), so this restores from the bundle rather than reconstructing from - /// the stub — the seed's stub is only ever a minimal veto marker. The stub is - /// removed first so neither a racing observer event nor a reconcile re-vetoes the - /// file we're about to write while the stub still exists. - func restoreSeed(_ seed: SeedLibrary.Seed) async { + /// Bring the on-disk seed set in line with the current bundle on a NON-empty + /// container (autoSeedIfEmpty owns the empty case). For each bundled seed, decide + /// via the pure `SeedReconcilePlanner`: + /// • live file present, up to date → skip + /// • live file present, older revision → overwrite with bundle bytes (upgrade) + /// • live file present, newer app version → skip (quarantine — never downgrade) + /// • no live file, stub present → skip (user deleted it; veto stands) + /// • no live file, no stub, name in use → skip (don't duplicate a same-name split) + /// • no live file, no stub, name free → write bundle bytes + /// + /// Safe even on a stale metadata index: writing bundle bytes another device also + /// wrote is a semantically empty conflict, and the stub + name guards are re-checked + /// right before each fresh write. + private func reconcileSeeds() async { guard let store, let tombstones else { return } - do { - try await tombstones.removeStub(at: "\(seed.id).json") - try await store.writeData(seed.data, to: seed.doc.relativePath) - CacheMapper.upsertSplit(seed.doc, relativePath: seed.doc.relativePath, into: context) - try context.save() - lastSyncError = nil - } catch { - report("Failed to restore starter split \(seed.doc.name)", error) + + let livePaths = Set(await store.list().filter { !$0.hasPrefix("Stubs/") }) + let stubIDs = await tombstones.listStubIDs() + let liveSplitNames = Set(((try? context.fetch(FetchDescriptor())) ?? []).map(\.name)) + + var didChange = false + for seed in SeedLibrary.seeds { + // Decode the live file at the seed's fixed path, if one exists. A file that + // exists but can't be read right now (evicted + download timed out) is left + // untouched — a later connect retries it. + var liveDoc: SplitDocument? + if livePaths.contains(seed.doc.relativePath) { + guard let data = try? await store.readData(from: seed.doc.relativePath), + let decoded = try? DocumentCoder.decode(SplitDocument.self, from: data) else { + continue + } + liveDoc = decoded + } + + let input = SeedReconcileInput( + seedID: seed.id, seedDoc: seed.doc, liveDoc: liveDoc, + hasStub: stubIDs.contains(seed.id) + ) + + switch SeedReconcilePlanner.decision(for: input, liveSplitNames: liveSplitNames) { + case .skip: + continue + case .upgrade: + // Overwriting an existing seed-path file with canonical bundle bytes is + // 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 + // index may have moved since the batch was gathered. + if await tombstones.stubExists(id: seed.id) { continue } + let names = Set(((try? context.fetch(FetchDescriptor())) ?? []).map(\.name)) + if names.contains(seed.doc.name) { continue } + if await writeSeedBytes(seed) { didChange = true } + } + } + + if didChange { + do { try context.save() } catch { report("Cache save failed", error) } + onCacheChanged?() } - onCacheChanged?() } - /// Whether a tombstone exists for `id` (placeholder-aware). The on-demand seed - /// path checks this to restore rather than re-create a deleted seed. - func isTombstoned(id: String) async -> Bool { - guard let tombstones else { return false } - return await tombstones.stubExists(id: id) + /// Force-restore the starter library on explicit user request ("Restore Starter + /// Splits"). For each seed with no live file and no live same-name split, lift its + /// veto stub if present — the ONE place the forever-veto is deliberately lifted — + /// and write the CURRENT bundle bytes (not the stub's old contents; the bundle is + /// the canonical restore source). Seeds whose live file already exists are left for + /// `reconcileSeeds` to upgrade. Returns how many seeds were (re)written. + @discardableResult + func restoreSeeds() async -> Int { + guard let store, let tombstones else { return 0 } + + let livePaths = Set(await store.list().filter { !$0.hasPrefix("Stubs/") }) + + var restored = 0 + for seed in SeedLibrary.seeds { + let liveNames = Set(((try? context.fetch(FetchDescriptor())) ?? []).map(\.name)) + guard SeedReconcilePlanner.shouldRestore( + hasLiveFile: livePaths.contains(seed.doc.relativePath), + seedName: seed.doc.name, + liveSplitNames: liveNames + ) else { continue } + + do { + // Lift the veto: drop the seed's tombstone stub if one exists, then + // write the verbatim bundle bytes. + if await tombstones.stubExists(id: seed.id) { + try await tombstones.removeStub(at: "\(seed.id).json") + } + try await store.writeData(seed.data, to: seed.doc.relativePath) + CacheMapper.upsertSplit(seed.doc, relativePath: seed.doc.relativePath, into: context) + restored += 1 + lastSyncError = nil + } catch { + report("Failed to restore starter split \(seed.doc.name)", error) + } + } + + if restored > 0 { + do { try context.save() } catch { report("Cache save failed", error) } + onCacheChanged?() + } + return restored + } + + /// Write a seed's verbatim bundle bytes and mirror it into the cache. Returns + /// whether the write succeeded (so the caller can flush + notify once at the end). + @discardableResult + private func writeSeedBytes(_ seed: SeedLibrary.Seed) async -> Bool { + guard let store else { return false } + do { + try await store.writeData(seed.data, to: seed.doc.relativePath) + CacheMapper.upsertSplit(seed.doc, relativePath: seed.doc.relativePath, into: context) + lastSyncError = nil + return true + } catch { + report("Failed to reconcile starter split \(seed.doc.name)", error) + return false + } + } + + // MARK: - Duplicate cleanup (dev) + + enum DuplicateScanError: Error, Sendable { + case notConnected + case unreadableFiles([String]) + } + + /// Scans every live (non-stub) document and builds a plan for exact-content + /// duplicate splits/workouts (see `DuplicateCleanupPlanner`). Read-only — no + /// files are touched. Fails closed: if ANY file can't be read or decoded, no + /// plan is produced, because an unreadable workout could reference any split + /// and silently proceeding could misjudge that split as unreferenced. + func scanForDuplicates() async throws -> DuplicateCleanupPlan { + guard let store else { throw DuplicateScanError.notConnected } + + let paths = await store.list().filter { !$0.hasPrefix("Stubs/") } + + var splits: [SplitDocument] = [] + var workouts: [WorkoutDocument] = [] + var referencedSplitIDs = Set() + var failedPaths: [String] = [] + + for path in paths { + let data: Data + do { + data = try await store.readData(from: path) + } catch { + log.error("scanForDuplicates: read failed for \(path, privacy: .public): \(error)") + failedPaths.append(path) + continue + } + + if path.hasPrefix("Splits/") { + do { + let doc = try DocumentCoder.decode(SplitDocument.self, from: data) + // Quarantined (written by a newer app version) splits are never + // judged as duplicates or deleted. + if doc.isReadable { splits.append(doc) } + } catch { + log.error("scanForDuplicates: decode failed for \(path, privacy: .public): \(error)") + failedPaths.append(path) + } + } else if path.hasPrefix("Workouts/") { + do { + let doc = try DocumentCoder.decode(WorkoutDocument.self, from: data) + // A quarantined workout's splitID still protects that split from + // deletion even though the workout itself is excluded below. + if let splitID = doc.splitID { + referencedSplitIDs.insert(splitID) + referencedSplitIDs.insert(currentSplitID(for: splitID)) + } + if doc.isReadable { workouts.append(doc) } + } catch { + log.error("scanForDuplicates: decode failed for \(path, privacy: .public): \(error)") + failedPaths.append(path) + } + } + } + + guard failedPaths.isEmpty else { + throw DuplicateScanError.unreadableFiles(failedPaths.sorted()) + } + + referencedSplitIDs.formUnion(cloneRedirects.values) + + return DuplicateCleanupPlanner.plan( + splits: splits, + workouts: workouts, + referencedSplitIDs: referencedSplitIDs, + isSeed: SeedLibrary.isSeed(id:) + ) + } + + /// Executes a previously scanned plan. Every deletion is re-checked + /// immediately beforehand against the *current* cache state — a watch push or + /// another device's write can land between scan and delete — so nothing + /// referenced or active is ever removed even if the plan is a few seconds stale. + func performCleanup(_ plan: DuplicateCleanupPlan) async -> (splitsDeleted: Int, workoutsDeleted: Int, skipped: Int) { + var splitsDeleted = 0 + var workoutsDeleted = 0 + var skipped = 0 + + for group in plan.splitGroups { + for doc in group.delete { + let splitID = doc.id + if SeedLibrary.isSeed(id: splitID) { + skipped += 1 + continue + } + let referencingWorkouts = (try? context.fetch( + FetchDescriptor(predicate: #Predicate { $0.splitID == splitID }) + )) ?? [] + guard referencingWorkouts.isEmpty else { + skipped += 1 + continue + } + if let cached = CacheMapper.fetchSplit(id: splitID, in: context) { + await softDelete(id: splitID, kind: "split", livePath: cached.jsonRelativePath) + } else { + await softDelete(id: splitID, kind: "split", livePath: doc.relativePath) + } + deleteCachedEntity(id: splitID) + splitsDeleted += 1 + } + } + + for group in plan.workoutGroups { + for doc in group.delete { + let cached = CacheMapper.fetchWorkout(id: doc.id, in: context) + if cached?.status == .inProgress { + skipped += 1 + continue + } + let livePath = cached?.jsonRelativePath ?? doc.relativePath + await softDelete(id: doc.id, kind: "workout", livePath: livePath) + deleteCachedEntity(id: doc.id) + workoutsDeleted += 1 + } + } + + saveCacheAndNotify() + return (splitsDeleted, workoutsDeleted, skipped) } // MARK: - Error Reporting diff --git a/Workouts/Views/Settings/DuplicateCleanupView.swift b/Workouts/Views/Settings/DuplicateCleanupView.swift new file mode 100644 index 0000000..1f13c43 --- /dev/null +++ b/Workouts/Views/Settings/DuplicateCleanupView.swift @@ -0,0 +1,281 @@ +// +// DuplicateCleanupView.swift +// Workouts +// +// Copyright 2025 Rouslan Zenetl. All Rights Reserved. +// + +import SwiftUI + +/// Developer-facing tool: scans iCloud Drive for splits/workouts that are exact +/// content duplicates (see `DuplicateCleanupPlanner`) — typically the residue of +/// testing, a restore, or a sync hiccup — and offers to delete the extras. Never +/// touches a split referenced by a workout, a bundled starter split, or an +/// in-progress workout. +struct DuplicateCleanupView: View { + @Environment(SyncEngine.self) private var sync + + private enum ScanFailure { + case unreadableFiles([String]) + case other(String) + } + + private struct CleanupSummary { + var splitsDeleted: Int + var workoutsDeleted: Int + var skipped: Int + } + + private enum Phase { + case idle + case scanning + case results(DuplicateCleanupPlan) + case failed(ScanFailure) + case deleting + case done(CleanupSummary) + } + + @State private var phase: Phase = .idle + @State private var pendingPlan: DuplicateCleanupPlan? + + var body: some View { + content + .navigationTitle("Clean Up Duplicates") + .confirmationDialog( + "Delete \(pendingPlan?.deleteCount ?? 0) Duplicate\((pendingPlan?.deleteCount ?? 0) == 1 ? "" : "s")?", + isPresented: Binding( + get: { pendingPlan != nil }, + set: { if !$0 { pendingPlan = nil } } + ), + titleVisibility: .visible, + presenting: pendingPlan + ) { plan in + Button("Delete", role: .destructive) { + pendingPlan = nil + Task { await performDelete(plan) } + } + Button("Cancel", role: .cancel) { + pendingPlan = nil + } + } message: { _ in + Text("This permanently deletes the duplicate copies shown below. Referenced splits, starter splits, and in-progress workouts are always kept.") + } + } + + @ViewBuilder + private var content: some View { + switch phase { + case .idle: + idleView + case .scanning: + ProgressView("Scanning…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + case .results(let plan): + resultsView(plan) + case .failed(let failure): + failureView(failure) + case .deleting: + ProgressView("Deleting…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + case .done(let summary): + doneView(summary) + } + } + + // MARK: - Idle + + private var idleView: some View { + Form { + Section { + Button { + Task { await scan() } + } label: { + Label("Scan for Duplicates", systemImage: "magnifyingglass") + } + } footer: { + Text("Looks for splits and workouts that are exact content duplicates — for example, left over from testing or a sync hiccup — and offers to delete the extra copies. Splits referenced by a workout, starter splits, and in-progress workouts are never deleted.") + } + } + } + + // MARK: - Results + + @ViewBuilder + private func resultsView(_ plan: DuplicateCleanupPlan) -> some View { + if plan.isEmpty { + ContentUnavailableView( + "No Duplicates Found", + systemImage: "checkmark.circle", + description: Text("Everything in iCloud looks unique.") + ) + } else { + List { + if !plan.splitGroups.isEmpty { + Section("Duplicate Splits") { + ForEach(plan.splitGroups) { group in + splitGroupRow(group) + } + } + } + if !plan.workoutGroups.isEmpty { + Section("Duplicate Workouts") { + ForEach(plan.workoutGroups) { group in + workoutGroupRow(group) + } + } + } + Section { + Button(role: .destructive) { + pendingPlan = plan + } label: { + HStack { + Spacer() + Text("Delete \(plan.deleteCount) Duplicate\(plan.deleteCount == 1 ? "" : "s")") + Spacer() + } + } + } + } + } + } + + private func splitGroupRow(_ group: DuplicateCleanupPlan.SplitGroup) -> some View { + VStack(alignment: .leading, spacing: 6) { + ForEach(group.keep) { doc in + memberRow(name: doc.name, detail: "\(doc.exercises.count) exercises", id: doc.id, isKeep: true) + } + ForEach(group.delete) { doc in + memberRow(name: doc.name, detail: "\(doc.exercises.count) exercises", id: doc.id, isKeep: false) + } + } + .padding(.vertical, 4) + } + + private func workoutGroupRow(_ group: DuplicateCleanupPlan.WorkoutGroup) -> some View { + VStack(alignment: .leading, spacing: 6) { + memberRow( + name: group.keep.splitName ?? "(no split)", + detail: group.keep.start.formattedDate(), + id: group.keep.id, + isKeep: true + ) + ForEach(group.delete) { doc in + memberRow( + name: doc.splitName ?? "(no split)", + detail: doc.start.formattedDate(), + id: doc.id, + isKeep: false + ) + } + } + .padding(.vertical, 4) + } + + private func memberRow(name: String, detail: String, id: String, isKeep: Bool) -> some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(name) + Text("\(detail) · id …\(id.suffix(6))") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + if isKeep { + Label("Keep", systemImage: "checkmark.circle") + .foregroundStyle(.secondary) + } else { + Label("Delete", systemImage: "trash") + .foregroundStyle(.red) + } + } + } + + // MARK: - Failure + + @ViewBuilder + private func failureView(_ failure: ScanFailure) -> some View { + switch failure { + case .unreadableFiles(let paths): + List { + Section { + Label( + "Scan aborted: \(paths.count) file\(paths.count == 1 ? "" : "s") could not be read. Nothing was deleted.", + systemImage: "exclamationmark.triangle.fill" + ) + .foregroundStyle(.orange) + } + Section("Unreadable Files") { + ForEach(paths, id: \.self) { path in + Text(path) + .font(.system(.footnote, design: .monospaced)) + } + } + Section { + Button("Rescan") { + Task { await scan() } + } + } + } + case .other(let message): + List { + Section { + Label(message, systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + } + Section { + Button("Try Again") { + Task { await scan() } + } + } + } + } + } + + // MARK: - Done + + private func doneView(_ summary: CleanupSummary) -> some View { + List { + Section { + Label( + "Deleted \(summary.splitsDeleted) split\(summary.splitsDeleted == 1 ? "" : "s"), \(summary.workoutsDeleted) workout\(summary.workoutsDeleted == 1 ? "" : "s"). \(summary.skipped) skipped (referenced or active).", + systemImage: "checkmark.circle.fill" + ) + .foregroundStyle(.green) + } + Section { + Button("Scan Again") { + Task { await scan() } + } + } + } + } + + // MARK: - Actions + + private func scan() async { + phase = .scanning + do { + let plan = try await sync.scanForDuplicates() + phase = .results(plan) + } catch let error as SyncEngine.DuplicateScanError { + switch error { + case .notConnected: + phase = .failed(.other("iCloud isn't connected yet. Try again in a moment.")) + case .unreadableFiles(let paths): + phase = .failed(.unreadableFiles(paths)) + } + } catch { + phase = .failed(.other(error.localizedDescription)) + } + } + + private func performDelete(_ plan: DuplicateCleanupPlan) async { + phase = .deleting + let result = await sync.performCleanup(plan) + phase = .done(CleanupSummary( + splitsDeleted: result.splitsDeleted, + workoutsDeleted: result.workoutsDeleted, + skipped: result.skipped + )) + } +} diff --git a/Workouts/Views/Settings/SettingsView.swift b/Workouts/Views/Settings/SettingsView.swift index 721fdc1..3ab4714 100644 --- a/Workouts/Views/Settings/SettingsView.swift +++ b/Workouts/Views/Settings/SettingsView.swift @@ -6,20 +6,18 @@ // import SwiftUI -import SwiftData import IndieAbout struct SettingsView: View { @Environment(SyncEngine.self) private var sync - @Environment(\.modelContext) private var modelContext @Environment(AppServices.self) private var services - @Query(sort: \Split.order) private var splits: [Split] - @AppStorage("restSeconds") private var restSeconds: Int = 45 @AppStorage("doneCountdownSeconds") private var doneCountdownSeconds: Int = 5 @AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb - @State private var showingAddSplitSheet = false + + @State private var isRestoringSeeds = false + @State private var restoreSeedsMessage: String? var body: some View { NavigationStack { @@ -53,62 +51,50 @@ struct SettingsView: View { Text("How long the watch waits on the finish screen before completing an exercise automatically.") } - // MARK: - Splits Section - Section(header: Text("Splits")) { - if splits.isEmpty { - HStack { - Spacer() - VStack(spacing: 8) { - Image(systemName: "dumbbell.fill") - .font(.largeTitle) - .foregroundColor(.secondary) - Text("No Splits Yet") - .font(.headline) - .foregroundColor(.secondary) - Text("Create a split to organize your workout routine.") - .font(.caption) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - } - .padding(.vertical) - Spacer() + // MARK: - Library Section + Section(header: Text("Library")) { + NavigationLink { + SplitListView() + } label: { + Label("Splits", systemImage: "dumbbell.fill") + } + + NavigationLink { + ExerciseLibraryView() + } label: { + Label("Exercises", systemImage: "figure.strengthtraining.traditional") + } + } + + // MARK: - Starter Splits Section + Section { + Button { + Task { + isRestoringSeeds = true + restoreSeedsMessage = nil + let n = await sync.restoreSeeds() + isRestoringSeeds = false + restoreSeedsMessage = n == 0 + ? "All starter splits are already present." + : "Restored \(n) starter split\(n == 1 ? "" : "s")." } + } label: { + HStack { + Label("Restore Starter Splits", systemImage: "arrow.counterclockwise") + if isRestoringSeeds { + Spacer() + ProgressView() + } + } + } + .disabled(isRestoringSeeds || sync.iCloudStatus != .available) + } header: { + Text("Starter Splits") + } footer: { + if let restoreSeedsMessage { + Text(restoreSeedsMessage) } else { - ForEach(splits) { split in - NavigationLink { - SplitDetailView(split: split) - } label: { - HStack { - Image(systemName: split.systemImage) - .foregroundColor(Color.color(from: split.color)) - .frame(width: 24) - Text(split.name) - Spacer() - Text("\(split.exercisesArray.count)") - .foregroundColor(.secondary) - } - } - } - } - - Button { - showingAddSplitSheet = true - } label: { - HStack { - Image(systemName: "plus.circle.fill") - .foregroundColor(.accentColor) - Text("Add Split") - } - } - - Button { - Task { await SplitSeeder.seedDefaults(into: modelContext, using: sync) } - } label: { - HStack { - Image(systemName: "wand.and.sparkles") - .foregroundColor(.accentColor) - Text("Add Starter Splits") - } + Text("Brings back the bundled starter splits you've deleted. Splits you've edited or created are never touched.") } } @@ -133,6 +119,17 @@ struct SettingsView: View { Text("iCloud Sync") } + // MARK: - Developer Section + Section { + NavigationLink { + DuplicateCleanupView() + } label: { + Label("Clean Up Duplicates", systemImage: "wrench.and.screwdriver") + } + } header: { + Text("Developer") + } + // MARK: - About Section Section { IndieAbout(configuration: AppInfoConfiguration( @@ -144,9 +141,6 @@ struct SettingsView: View { } } .navigationTitle("Settings") - .sheet(isPresented: $showingAddSplitSheet) { - SplitAddEditView(split: nil) - } .onChange(of: restSeconds) { _, _ in services.watchBridge.pushAll() } .onChange(of: doneCountdownSeconds) { _, _ in services.watchBridge.pushAll() } .onChange(of: weightUnit) { _, _ in services.watchBridge.pushAll() } diff --git a/WorkoutsTests/DuplicateCleanupPlannerTests.swift b/WorkoutsTests/DuplicateCleanupPlannerTests.swift new file mode 100644 index 0000000..1007324 --- /dev/null +++ b/WorkoutsTests/DuplicateCleanupPlannerTests.swift @@ -0,0 +1,198 @@ +import Foundation +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 +/// 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 +/// keep every assertion deterministic. +struct DuplicateCleanupPlannerTests { + + // MARK: - Fixtures + + private func id(_ n: Int) -> String { + "01TESTID" + String(repeating: "0", count: 16) + String(format: "%02d", n) + } + + private func exercise( + name: String = "Bench Press", order: Int = 0, sets: Int = 3, reps: Int = 10, + weight: Int = 100, loadType: Int = LoadType.weight.rawValue, duration: Int = 0 + ) -> ExerciseDocument { + ExerciseDocument( + id: "EX-\(name)-\(order)", name: name, order: order, sets: sets, reps: reps, weight: weight, + loadType: loadType, durationSeconds: duration + ) + } + + private func split( + id splitID: String, name: String = "Push Day", exercises: [ExerciseDocument]? = nil, + createdAt: Date = Date(timeIntervalSince1970: 0) + ) -> SplitDocument { + SplitDocument( + schemaVersion: SplitDocument.currentSchemaVersion, id: splitID, name: name, color: "indigo", + systemImage: "dumbbell.fill", order: 0, createdAt: createdAt, updatedAt: createdAt, + exercises: exercises ?? [exercise()], activityType: nil + ) + } + + private func log(name: String = "Bench Press", order: Int = 0, date: Date = Date(timeIntervalSince1970: 0)) -> WorkoutLogDocument { + WorkoutLogDocument( + id: "LOG-\(name)-\(order)", exerciseName: name, order: order, sets: 3, reps: 10, weight: 100, + loadType: LoadType.weight.rawValue, durationSeconds: 0, currentStateIndex: 0, + status: WorkoutStatus.completed.rawValue, notes: nil, date: date + ) + } + + private func workout( + id workoutID: String, splitID: String? = "SPLIT-1", splitName: 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, + logs: logs ?? [log(date: start)], metrics: metrics + ) + } + + /// A fixed calendar day/time built via `Calendar.current`, so it agrees with + /// the planner's own `Calendar.current`-based day bucketing regardless of the + /// test runner's time zone. + private func fixedDate(day: Int = 15, hour: Int = 9) -> Date { + var comps = DateComponents() + comps.year = 2024 + comps.month = 3 + comps.day = day + comps.hour = hour + return Calendar.current.date(from: comps)! + } + + private func metrics(healthKitWorkoutUUID: String? = nil, recordedAt: Date = Date(timeIntervalSince1970: 0)) -> WorkoutMetrics { + WorkoutMetrics( + activeEnergyKcal: nil, avgHeartRate: nil, maxHeartRate: nil, minHeartRate: nil, + totalVolume: nil, hrZoneSeconds: nil, healthKitWorkoutUUID: healthKitWorkoutUUID, + source: .watch, recordedAt: recordedAt + ) + } + + // MARK: - Split survivor rules + + @Test func identicalUnreferencedSplitsKeepEarliestULID() { + let earlier = split(id: id(1)) + let later = split(id: id(2)) + let plan = DuplicateCleanupPlanner.plan( + splits: [later, earlier], workouts: [], referencedSplitIDs: [], 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)]) + } + + /// Referenced-protection must override "earliest ULID wins": a later, + /// referenced split 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 plan = DuplicateCleanupPlanner.plan( + splits: [earlier, later], workouts: [], referencedSplitIDs: [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)]) + } + + @Test func seedMemberNeverDeleted() throws { + let seedSplit = split(id: id(1)) + let duplicate = split(id: id(2)) + let plan = DuplicateCleanupPlanner.plan( + splits: [seedSplit, duplicate], workouts: [], referencedSplitIDs: [], + isSeed: { $0 == id(1) } + ) + let group = try #require(plan.splitGroups.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 plan = DuplicateCleanupPlanner.plan( + splits: [a, b], workouts: [], referencedSplitIDs: [], isSeed: { _ in false } + ) + #expect(plan.splitGroups.isEmpty) + } + + /// Machine comfort settings are real content: splits 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() { + var lowSeat = exercise() + lowSeat.machineSettings = [MachineSetting(name: "Seat Height", value: "4")] + var highSeat = exercise() + highSeat.machineSettings = [MachineSetting(name: "Seat Height", value: "5")] + 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 plan = DuplicateCleanupPlanner.plan( + splits: [a, b, c, d], workouts: [], referencedSplitIDs: [], isSeed: { _ in false } + ) + #expect(plan.splitGroups.isEmpty) + } + + // MARK: - Workout grouping / survivor rules + + @Test func sameDayWorkoutsDifferingOnlyInIDsAndTimestampsAreGrouped() { + let day = fixedDate(hour: 9) + let sameDayLater = fixedDate(hour: 20) + 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 } + ) + #expect(plan.workoutGroups.count == 1) + #expect(plan.workoutGroups[0].keep.id == id(1)) + #expect(plan.workoutGroups[0].delete.map(\.id) == [id(2)]) + } + + @Test func differentDaysNotGrouped() { + 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 } + ) + #expect(plan.workoutGroups.isEmpty) + } + + @Test func inProgressMemberDropsEntireGroup() { + let start = fixedDate() + 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 } + ) + #expect(plan.workoutGroups.isEmpty) + } + + /// The HealthKit-link holder must survive even though the other copy has the + /// lexicographically smaller (earlier) id — deleting the link holder would + /// cascade-delete the real Health sample. + @Test func healthKitLinkHolderSurvivesOverEarlierULID() throws { + let start = fixedDate() + 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 } + ) + let group = try #require(plan.workoutGroups.first) + #expect(group.keep.id == id(2)) + #expect(group.delete.map(\.id) == [id(1)]) + } +} diff --git a/WorkoutsTests/SeedReconcilePlannerTests.swift b/WorkoutsTests/SeedReconcilePlannerTests.swift new file mode 100644 index 0000000..f89456a --- /dev/null +++ b/WorkoutsTests/SeedReconcilePlannerTests.swift @@ -0,0 +1,137 @@ +import Foundation +import Testing +import IndieSync +@testable import Workouts + +/// 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 +/// 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 { + + // MARK: - Fixtures + + private func exercise( + name: String, order: Int = 0, sets: Int = 3, reps: Int = 10, weight: Int = 100 + ) -> ExerciseDocument { + ExerciseDocument( + id: "EX-\(name)-\(order)", name: name, order: order, sets: sets, reps: reps, + weight: weight, loadType: LoadType.weight.rawValue, durationSeconds: 0 + ) + } + + /// A seed-shaped split 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, + exercises: [ExerciseDocument]? = nil + ) -> SplitDocument { + SplitDocument( + schemaVersion: schemaVersion, id: id, name: name, color: "indigo", + systemImage: "dumbbell.fill", order: 0, + createdAt: Date(timeIntervalSince1970: 0), updatedAt: Date(timeIntervalSince1970: 0), + exercises: exercises ?? [exercise(name: "Bench Press")], activityType: nil + ) + } + + // MARK: - Live file present + + /// An older app version wrote this seed at schemaVersion 1 (and, on disk, carried + /// extra now-removed keys — which decode into the current struct as simply absent). + /// The content is otherwise identical, so normalizing the version stamp must make it + /// read as up to date: no churn, no rewrite. + @Test func semanticEqualitySkipsAcrossSchemaVersions() { + let bundle = seed() // current schemaVersion + var live = bundle + 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)) + } + + /// The bundle seed gained an exercise since this install wrote its file — a real + /// content change must upgrade (overwrite with current bundle bytes), even though + /// the live file is a lower schema version. + @Test func contentChangeUpgrades() { + let bundle = seed(exercises: [exercise(name: "Bench Press"), exercise(name: "Overhead Press", order: 1)]) + var live = seed(exercises: [exercise(name: "Bench Press")]) // one fewer exercise + live.schemaVersion = 1 + let input = SeedReconcileInput(seedID: bundle.id, seedDoc: bundle, liveDoc: live, hasStub: false) + + #expect(SeedReconcilePlanner.decision(for: input, liveSplitNames: []) == .upgrade) + } + + /// A weight tweak alone is enough of a content change to upgrade. + @Test func exerciseWeightChangeUpgrades() { + let bundle = seed(exercises: [exercise(name: "Bench Press", weight: 135)]) + 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) + } + + /// A live file written by a NEWER app version (schemaVersion above current) is + /// quarantined — never downgrade-rewrite it, even though it differs. + @Test func newerSchemaFileIsQuarantined() { + let bundle = seed() + var live = seed(exercises: [exercise(name: "Something New")]) + live.schemaVersion = SplitDocument.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)) + } + + // MARK: - No live file + + /// No live file but a stub exists → the user deleted this seed. The veto stands. + @Test func stubVetoesResurrection() { + let bundle = seed() + let input = SeedReconcileInput(seedID: bundle.id, seedDoc: bundle, liveDoc: nil, hasStub: true) + + #expect(SeedReconcilePlanner.decision(for: input, liveSplitNames: []) == .skip(.vetoed)) + } + + /// No live file, no stub, but a live split (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)) + } + + /// No live file, no stub, no same-name split → 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) + } + + /// A stub present but the same name also live: the veto is checked first, so it + /// still skips (as vetoed) rather than as a name collision — either way, no write. + @Test func stubTakesPrecedenceOverNameCollision() { + 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)) + } + + // MARK: - Restore + + /// Restore rewrites only a seed that is both absent and free of a name collision; + /// 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: [])) + // 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"])) + } +}