diff --git a/CHANGELOG.md b/CHANGELOG.md index be504cb..ee50fa6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ **July 2026** +The watch workout view now closes reliably when you end a workout on your iPhone, instead of the app reappearing on every wrist raise. + Watch and iPhone set timers no longer drift apart when the watch sleeps through the end of a rest. Swiping through sets on the iPhone now reliably moves the watch along too, instead of the two drifting apart mid-workout. diff --git a/PLAN-watch-session-end.md b/PLAN-watch-session-end.md new file mode 100644 index 0000000..c0e6954 --- /dev/null +++ b/PLAN-watch-session-end.md @@ -0,0 +1,241 @@ +# Plan — Fix the watch workout-session leak (reliable session end) + +Fixes the bug behind "when the split ends, the watch app keeps coming back up even if I +close it." Companion to [`WATCH-SYNC-SCENARIOS.md`](WATCH-SYNC-SCENARIOS.md) (scenarios +S4 / S6 / S7 and the closing section). + +## Problem + +The watch's `HKWorkoutSession` grants the app persistent foreground runtime — a running +session is *exactly* what makes watchOS keep re-foregrounding the app on every wrist +raise. The session must be ended (`finishAndSave()` or `discard()`) when the run ends. + +Today the **only** thing that ends it is `ActiveWorkoutGateView`'s +`.onChange(of: activeWorkouts)` firing on a non-empty → empty transition. That requires a +**mounted, foregrounded gate view** to witness the transition. When the run ends from the +**phone** (S4 complete-last-exercise, S6 "Save Workout" / start-new-split, S7 discard) the +watch app is typically backgrounded — kept alive only by the session — or gets torn down +and rebuilt by watchOS with an already-empty list. The `onChange` never fires, the session +stays `running`, and the app leaks. The watch-originated end (S5) works only because that +view happens to be alive on the watch at that moment. + +## Root cause + +Ending the session is a **lifecycle/data concern implemented as a view-level side +effect.** The trigger is bound to SwiftUI view lifetime instead of the authoritative data. + +## Design principle (already validated in this codebase) + +Commit `8cbe078` fixed the *same class* of fragility in the ephemeral live-run mirror by +adding `repairFromDurable` — the run screen stops trusting the transient frame and +**reconciles against the authoritative `WorkoutDocument`**. We apply the same move one +layer down: drive `session.end()` from the authoritative cache apply, in a **long-lived +coordinator** that survives view rebuilds and runs on the WatchConnectivity delegate +callback (which fires with or without any mounted view, including in the +session's background runtime). + +No new wire message is required — the authoritative push already carries the signal (the +run leaves the active set on completion, or is pruned entirely on discard). A dedicated +durable "run ended" wire type was considered and rejected for now: it widens the protocol +for no gain over reconciling the state we already push. + +## The fix + +### 1. New pure decision seam — `SessionEndPlanner` + +Mirrors the existing `WatchCacheApplier` pattern: a pure, session-free function that is +trivially unit-testable, with the impure glue kept thin around it. + +```swift +enum SessionEndDecision: Equatable { + case none + case discard + case finish(WorkoutDocument) // save to Health, attach metrics, forward to phone +} + +enum SessionEndPlanner { + static func activeIDs(in workouts: [WorkoutDocument]) -> Set { + Set(workouts + .filter { [.notStarted, .inProgress].contains(WorkoutStatus(rawValue: $0.status)) } + .map(\.id)) + } + + /// `previouslyActiveIDs` is the active set captured at the *last* reconcile. The end + /// only fires on a genuine non-empty → empty transition, which is what makes the + /// launch race safe: at startup the session may be `running` before the run document + /// has arrived, but `previouslyActiveIDs` is still empty, so we never discard a run + /// we simply haven't heard about yet. + static func decide(isRunning: Bool, + previouslyActiveIDs: Set, + workouts: [WorkoutDocument]) -> SessionEndDecision { + guard isRunning, + activeIDs(in: workouts).isEmpty, + !previouslyActiveIDs.isEmpty else { return .none } + let finished = previouslyActiveIDs + .compactMap { id in workouts.first { $0.id == id } } + .first { WorkoutStatus(rawValue: $0.status) == .completed } + return finished.map(SessionEndDecision.finish) ?? .discard + } +} +``` + +### 2. New `WorkoutSessionCoordinator` (watch, `@MainActor`, owned by `WatchAppServices`) + +Holds the same `WorkoutSessionManager` instance the delegate starts, plus the cache. Its +`reconcile()` is the single owner of session-end; `endAndForward` is lifted verbatim from +today's `ActiveWorkoutGateView.endSession`. + +```swift +@MainActor +final class WorkoutSessionCoordinator { + private let sessionManager: WorkoutSessionManager + private let bridge: WatchConnectivityBridge // to forward captured metrics + private let context: ModelContext + private var previouslyActiveIDs: Set = [] + private var isEnding = false // guards the async finish from re-entry + + init(sessionManager: WorkoutSessionManager, bridge: WatchConnectivityBridge, context: ModelContext) { … } + + func reconcile() { + guard !isEnding else { return } + let workouts = (try? context.fetch(FetchDescriptor()))? + .map(WorkoutDocument.init(from:)) ?? [] + let decision = SessionEndPlanner.decide( + isRunning: sessionManager.isRunning, + previouslyActiveIDs: previouslyActiveIDs, + workouts: workouts) + previouslyActiveIDs = SessionEndPlanner.activeIDs(in: workouts) + switch decision { + case .none: return + case .discard: sessionManager.discard() + case .finish(let doc): endAndForward(doc) + } + } + + private func endAndForward(_ doc: WorkoutDocument) { + isEnding = true + Task { + defer { isEnding = false } + guard var metrics = await sessionManager.finishAndSave() else { return } + metrics.totalVolume = WorkoutVolume.total(doc.logs) + var updated = doc + updated.metrics = metrics + updated.updatedAt = Date() + bridge.update(workout: updated) // optimistic upsert + forward (queued if offline) + } + } +} +``` + +The watch cache holds only active + recently-pruned runs, so the fetch-all-map in +`reconcile()` is bounded and cheap. + +### 3. Wiring + +- **`WatchConnectivityBridge`** — add `var onWorkoutsChanged: (() -> Void)?` and fire it + after every authoritative cache mutation, once the write is committed: + - at the end of `applyState(...)` **on the success branch** (after `lastSyncDate = Date()`); + - at the end of `update(workout:)` (after `try? context.save()`). + These are the two places the watch's workout set changes: phone pushes (S4/S6/S7) and + the watch's own optimistic edits (S5, including offline). +- **`WatchAppServices`** — hold the coordinator; replace `activate()` with + `bootstrap(sessionManager:)` that stores the passed-in manager, builds the coordinator, + sets `bridge.onWorkoutsChanged = { [weak coordinator] in coordinator?.reconcile() }` + (weak break for the retain cycle), calls `bridge.activate()`, then calls + `coordinator.reconcile()` once to seed `previouslyActiveIDs` from whatever is already + applied. +- **`WorkoutsWatchApp`** — `.task { services.bootstrap(sessionManager: appDelegate.sessionManager) }`. + The same `appDelegate.sessionManager` instance stays injected into the view tree via + `.environment(...)` (the live HUD still reads it) — ownership is unchanged; the + coordinator merely references it. +- **`ActiveWorkoutGateView`** — delete `.onChange(of: activeWorkouts.map(\.id))`, delete + `endSession(previouslyActive:)`, and drop the now-unused + `@Environment(WorkoutSessionManager.self)`. Everything else (list, `requestSync`, + `popIfNavigatedRunUnavailable`) is untouched. The session end now has exactly one owner. +- **XcodeGen** — run `xcodegen generate` (new source file in the watch target; `project.yml` + already globs `Workouts Watch App/`, so no `project.yml` edit). + +### Fixed flow (contrast with S4/S6 in the scenarios doc) + +```mermaid +sequenceDiagram + participant PB as PhoneBridge + participant WB as WatchBridge + participant Coord as SessionCoordinator + participant WS as Watch session + + PB->>WB: appContext (run completed — leaves active set) / (run pruned — discard) + WB->>WB: WatchCacheApplier.apply → context.save + WB->>Coord: onWorkoutsChanged → reconcile() + Coord->>Coord: decide(isRunning, previouslyActive, cache) + alt was active, now empty, a completed run survives + Coord->>WS: finishAndSave() → session.end() + save HKWorkout + Coord->>WB: update(workout + metrics) → forward to phone + else was active, now empty, run absent + Coord->>WS: discard() → session.end() + end + Note over WB,Coord: runs on the delegate callback — no mounted view required +``` + +## Why this covers every scenario + +| Scenario | Trigger for `reconcile()` | Result | +|---|---|---| +| S1 launch race (session running before run doc lands) | first `applyState` | `previouslyActive` empty → **no** premature discard | +| S4 phone completes last exercise (watch backgrounded) | `applyState` on the push | session ends — no view needed | +| S6 phone "Save Workout" / start-new-split | `applyState` on the push | session ends | +| S7 phone discards | `applyState` on the prune push | `discard()` | +| S5 watch completes last exercise | local `update(workout:)` | ends immediately, **works offline** (no phone round-trip) | +| Watch relaunched cold | `bootstrap` seed reconcile | `isRunning == false` → no-op | + +## Testing + +- **`SessionEndPlannerTests`** (new, `Workouts Watch AppTests`, pure — no HealthKit / no + SwiftData), pinning the decision table: + - launch race: `isRunning`, prev `[]`, workouts `[]` → `.none` + - complete: prev `["A"]`, workouts `[A.completed]` → `.finish(A)` + - discard: prev `["A"]`, workouts `[]` → `.discard` + - partial: prev `["A","B"]`, workouts `[A.completed, B.inProgress]` → `.none` + - not running: `isRunning == false` → `.none` + - already ended: prev `[]`, workouts `[A.completed]` → `.none` + - `activeIDs(in:)` classification +- The coordinator itself stays a thin fetch-and-delegate shell (the same reason + `WatchCacheApplier` is tested but `WatchConnectivityBridge` is not) — `WorkoutSessionManager` + wraps `HKHealthStore`, which isn't unit-testable. +- **Manual device verification** (the real proof — this is inherently a background / + view-lifecycle bug): + 1. Start a split on the phone → watch launches, session runs. + 2. With the **watch screen off / app backgrounded**, tap **Save Workout** on the phone → + the watch app must stop resurfacing on wrist raise; the workout must appear in Health. + 3. Repeat with **Discard** → app stops resurfacing; nothing saved to Health. + 4. Start a split, put the **phone in airplane mode**, finish on the watch → session ends + locally; metrics forward once connectivity returns. + 5. Start a split and immediately background the watch before the doc syncs → confirm the + session is **not** discarded (launch race). + +## Out of scope (tracked separately) + +- **Per-log merge in `ingestFromWatch`** — the timestamp-not-content intake gate + (documented at `SyncEngine.ingestFromWatch`; memory item "H1 per-log merge"). Independent + of session end. +- **A durable "run ended" wire signal** — considered and deferred; the authoritative state + push is a sufficient signal. +- **System-initiated session ends** (`WorkoutSessionManager.workoutSession(_:didChangeTo:)`, + the `.ended`/`.stopped` branch) only `clear()` local state — no `finishWorkout()` save, no + metrics forwarded. This coordinator handles *app*-driven ends (the leak); a watchOS-driven + end (rare — e.g. the OS reclaims the session) still drops the HKWorkout and metrics. Left + as a known residual: there may be no run document to attach to at that moment, and the + frequency is low. + +## Status + +Implemented (session-end coordinator + `SessionEndPlanner` + `SessionEndPlannerTests`); +`ActiveWorkoutGateView`'s view-level trigger removed. The per-log merge and the residual +above remain open. + +## Rollout + +Single watch-side change set, no schema or wire-format change, no phone-side edits. Ship +behind normal build; verify on device with the checklist above; add a one-line CHANGELOG +entry (end-user visible: "The watch workout view now closes reliably when you end a workout +on your iPhone"). diff --git a/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift b/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift index ac33b55..b4dab19 100644 --- a/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift +++ b/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift @@ -19,6 +19,12 @@ final class WatchConnectivityBridge: NSObject { /// Last time state was received from the phone (for a sync indicator). private(set) var lastSyncDate: Date? + /// Fired after every authoritative cache mutation (a phone push applied, or the watch's + /// own optimistic `update(workout:)`), once the write is committed. The + /// `WorkoutSessionCoordinator` hangs off this to end the `HKWorkoutSession` from the + /// authoritative data rather than a view observer — see `WorkoutSessionCoordinator`. + var onWorkoutsChanged: (() -> Void)? + /// Exclusive-edit lock pushed by the phone. While set, the watch parks the matching /// run (popping out of its progress view) and blocks re-entry, so the phone owns the /// edit and the watch can't clobber it with a stale optimistic write. `editingWorkoutID` @@ -113,6 +119,7 @@ final class WatchConnectivityBridge: NSObject { } Self.log.info("applyState: applied \(splits?.count ?? 0) splits, \(workouts?.count ?? 0) workouts") lastSyncDate = Date() + onWorkoutsChanged?() } func requestSync() { @@ -128,6 +135,7 @@ final class WatchConnectivityBridge: NSObject { func update(workout doc: WorkoutDocument) { CacheMapper.upsertWorkout(doc, relativePath: doc.relativePath, into: context) try? context.save() + onWorkoutsChanged?() sendToPhone(doc) } diff --git a/Workouts Watch App/Views/ActiveWorkoutGateView.swift b/Workouts Watch App/Views/ActiveWorkoutGateView.swift index 4ddb092..650a2fd 100644 --- a/Workouts Watch App/Views/ActiveWorkoutGateView.swift +++ b/Workouts Watch App/Views/ActiveWorkoutGateView.swift @@ -18,7 +18,6 @@ import SwiftData /// exercise list. struct ActiveWorkoutGateView: View { @Environment(WatchConnectivityBridge.self) private var bridge - @Environment(WorkoutSessionManager.self) private var sessionManager @Query(sort: \Workout.start, order: .reverse) private var workouts: [Workout] @Query private var splits: [Split] @@ -72,13 +71,6 @@ struct ActiveWorkoutGateView: View { // Nothing to run yet — pull fresh state in case the phone just started one. if activeWorkouts.isEmpty { bridge.requestSync() } } - .onChange(of: activeWorkouts.map(\.id)) { previouslyActive, nowActive in - // The active list just emptied — the run either completed or was discarded. - // Route the HealthKit session accordingly. - if nowActive.isEmpty, !previouslyActive.isEmpty { - endSession(previouslyActive: previouslyActive) - } - } // The phone just entered (or left) an editor — if we're inside the now-locked run, // pop back to the gate so re-entry rebuilds a fresh working copy. Also pop if the run // we're inside was pruned (discarded/deleted on the phone, or aged out of the push). @@ -98,35 +90,6 @@ struct ActiveWorkoutGateView: View { } } - /// The active list emptied: decide whether the run completed (save it to Health, - /// attach the captured metrics, and forward them to the phone) or was discarded - /// (throw the session data away). A completed run stays in the cache as `.completed`; - /// a discarded one is tombstoned on the phone and pruned from the cache, so its - /// absence from `workouts` is the discard signal. - private func endSession(previouslyActive ids: [String]) { - let finished = ids - .compactMap { id in workouts.first { $0.id == id } } - .first { $0.status == .completed } - - guard let finished else { - // Nothing survived as completed → the run was discarded. Don't save it. - sessionManager.discard() - return - } - - let doc = WorkoutDocument(from: finished) - Task { - guard var metrics = await sessionManager.finishAndSave() else { return } - metrics.totalVolume = WorkoutVolume.total(doc.logs) - var updated = doc - updated.metrics = metrics - updated.updatedAt = Date() - // Carry the captured metrics back to the phone (and thus iCloud) over the - // existing workout-update path. - bridge.update(workout: updated) - } - } - /// If the run we're currently navigated into is no longer available — pruned from the /// cache (discarded/deleted on the phone, or aged out of the pushed set), or locked /// because the phone took over editing it — pop back to the gate. diff --git a/Workouts Watch App/WatchAppServices.swift b/Workouts Watch App/WatchAppServices.swift index d15ae87..0d98b6b 100644 --- a/Workouts Watch App/WatchAppServices.swift +++ b/Workouts Watch App/WatchAppServices.swift @@ -11,6 +11,10 @@ final class WatchAppServices { let container: ModelContainer let bridge: WatchConnectivityBridge + /// The single owner of workout-session termination. Built in `bootstrap` once we have the + /// delegate's session manager to reference; nil until then. + private var sessionCoordinator: WorkoutSessionCoordinator? + init() { let container = WorkoutsModelContainer.make() self.container = container @@ -20,7 +24,22 @@ final class WatchAppServices { #endif } - func activate() { + /// Wire the session coordinator to the (delegate-owned) session manager, start the bridge, + /// and seed the coordinator's baseline from whatever state is already applied. The manager + /// is passed in rather than owned here so the delegate's `handle(_:)` and the view tree's + /// `.environment(...)` keep referencing the same instance. + func bootstrap(sessionManager: WorkoutSessionManager) { + let coordinator = WorkoutSessionCoordinator( + sessionManager: sessionManager, + bridge: bridge, + context: container.mainContext) + self.sessionCoordinator = coordinator + // weak break: the coordinator is held here; the bridge only borrows it. + bridge.onWorkoutsChanged = { [weak coordinator] in coordinator?.reconcile() } bridge.activate() + // Seed `previouslyActiveIDs` from whatever the bridge already applied at launch, so the + // first real change is measured against a correct baseline (and the launch race — a + // running session before the run doc arrives — resolves to `.none`, never a discard). + coordinator.reconcile() } } diff --git a/Workouts Watch App/WorkoutSessionCoordinator.swift b/Workouts Watch App/WorkoutSessionCoordinator.swift new file mode 100644 index 0000000..d3433b3 --- /dev/null +++ b/Workouts Watch App/WorkoutSessionCoordinator.swift @@ -0,0 +1,119 @@ +// +// WorkoutSessionCoordinator.swift +// Workouts Watch App +// +// Copyright 2025 Rouslan Zenetl. All Rights Reserved. +// + +import Foundation +import SwiftData + +/// What the watch should do with its `HKWorkoutSession` given the latest authoritative +/// workout set. `finish` carries the completed run whose metrics we save + forward. +enum SessionEndDecision: Equatable { + case none + case discard + case finish(WorkoutDocument) +} + +/// Pure decision seam for ending the watch's workout session — the session-free core the +/// `WorkoutSessionCoordinator` delegates to, mirroring the `WatchCacheApplier` pattern so the +/// end-of-run rule is unit-testable without HealthKit or a live cache. +/// +/// The rule reconciles against the *authoritative* workout set (the same set the phone pushes +/// and the watch prunes to), not against any view's lifetime — a running session ends on a +/// genuine non-empty → empty transition of the active set. Comparing against the active set +/// captured at the *previous* reconcile is what makes the launch race safe: at startup the +/// session can be `running` before the run document has synced, but the previous active set is +/// still empty, so we never discard a run we simply haven't heard about yet. +enum SessionEndPlanner { + /// Ids of the runs that keep the session alive — not-yet-finished (`notStarted` / + /// `inProgress`). A `completed` / `skipped` run has dropped off the active set. + static func activeIDs(in workouts: [WorkoutDocument]) -> Set { + Set(workouts + .filter { + let status = WorkoutStatus(rawValue: $0.status) + return status == .notStarted || status == .inProgress + } + .map(\.id)) + } + + static func decide(isRunning: Bool, + previouslyActiveIDs: Set, + workouts: [WorkoutDocument]) -> SessionEndDecision { + guard isRunning, + activeIDs(in: workouts).isEmpty, + !previouslyActiveIDs.isEmpty else { return .none } + // A previously-active run that survived as `.completed` is a finish (save + forward + // metrics); if none survived, every active run vanished (discarded/deleted) → discard. + let finished = previouslyActiveIDs + .compactMap { id in workouts.first { $0.id == id } } + .first { WorkoutStatus(rawValue: $0.status) == .completed } + return finished.map(SessionEndDecision.finish) ?? .discard + } +} + +/// The single owner of watch workout-session termination. Long-lived (held by +/// `WatchAppServices`, not a view), so it runs on the WatchConnectivity delegate callback — +/// with or without a mounted view, including in the session's own background runtime. This is +/// the fix for the leak where ending a run *from the phone* while the watch app was +/// backgrounded left the `HKWorkoutSession` running (watchOS then re-foregrounded the app on +/// every wrist raise) and skipped the metrics capture entirely. +/// +/// Same move as the live-mirror's `repairFromDurable`, one layer down: drive the terminal +/// action from the authoritative cache apply rather than a transient UI observer. +@MainActor +final class WorkoutSessionCoordinator { + private let sessionManager: WorkoutSessionManager + private let bridge: WatchConnectivityBridge + private let context: ModelContext + + /// The active set captured at the previous reconcile — the baseline the non-empty → empty + /// transition is measured against (see `SessionEndPlanner`). + private var previouslyActiveIDs: Set = [] + + /// Guards the async finish so a burst of reconciles can't double-end the session. + private var isEnding = false + + init(sessionManager: WorkoutSessionManager, bridge: WatchConnectivityBridge, context: ModelContext) { + self.sessionManager = sessionManager + self.bridge = bridge + self.context = context + } + + /// Re-evaluate the session against the current authoritative cache. Called after every + /// cache mutation (phone push or the watch's own optimistic edit) and once at bootstrap to + /// seed the baseline. The watch cache holds only active + recently-pruned runs, so the + /// fetch-and-map is bounded and cheap. + func reconcile() { + guard !isEnding else { return } + let workouts = ((try? context.fetch(FetchDescriptor())) ?? []) + .map(WorkoutDocument.init(from:)) + let decision = SessionEndPlanner.decide( + isRunning: sessionManager.isRunning, + previouslyActiveIDs: previouslyActiveIDs, + workouts: workouts) + previouslyActiveIDs = SessionEndPlanner.activeIDs(in: workouts) + switch decision { + case .none: return + case .discard: sessionManager.discard() + case .finish(let doc): endAndForward(doc) + } + } + + /// Finish the session (saving the `HKWorkout` to Health), attach the captured metrics, and + /// forward the run back to the phone. Lifted from the old `ActiveWorkoutGateView.endSession` + /// so the behavior is unchanged — only its trigger moved off the view. + private func endAndForward(_ doc: WorkoutDocument) { + isEnding = true + Task { + defer { isEnding = false } + guard var metrics = await sessionManager.finishAndSave() else { return } + metrics.totalVolume = WorkoutVolume.total(doc.logs) + var updated = doc + updated.metrics = metrics + updated.updatedAt = Date() + bridge.update(workout: updated) // optimistic upsert + forward (queued if offline) + } + } +} diff --git a/Workouts Watch App/WorkoutsApp.swift b/Workouts Watch App/WorkoutsApp.swift index f00e140..92b3ade 100644 --- a/Workouts Watch App/WorkoutsApp.swift +++ b/Workouts Watch App/WorkoutsApp.swift @@ -32,6 +32,6 @@ struct WorkoutsWatchApp: App { .environment(services.bridge) .environment(appDelegate.sessionManager) .modelContainer(services.container) - .task { services.activate() } + .task { services.bootstrap(sessionManager: appDelegate.sessionManager) } } } diff --git a/Workouts Watch AppTests/SessionEndPlannerTests.swift b/Workouts Watch AppTests/SessionEndPlannerTests.swift new file mode 100644 index 0000000..1942d83 --- /dev/null +++ b/Workouts Watch AppTests/SessionEndPlannerTests.swift @@ -0,0 +1,89 @@ +import Foundation +import Testing +@testable import Workouts_Watch_App + +/// Pins the pure session-end decision (`SessionEndPlanner.decide`) — the rule that ends the +/// watch's `HKWorkoutSession` from the authoritative workout set instead of a view observer. +/// The decisive properties: a running session ends only on a genuine non-empty → empty +/// transition of the active set; a survivor that's `.completed` is a finish, otherwise a +/// discard; and the launch race (session running before the run doc has synced) resolves to +/// `.none` so a real run is never discarded before we've heard about it. +struct SessionEndPlannerTests { + private static let ts = Date(timeIntervalSince1970: 1_700_000_000) + + private func workout(_ id: String, _ status: WorkoutStatus) -> WorkoutDocument { + WorkoutDocument( + schemaVersion: WorkoutDocument.currentSchemaVersion, + id: id, splitID: nil, splitName: nil, start: Self.ts, end: nil, + status: status.rawValue, createdAt: Self.ts, updatedAt: Self.ts, + logs: [], metrics: nil) + } + + // MARK: - decide + + @Test func launchRace_runningButNothingHeardYet_isNone() { + // Session running before any run document has synced: previous active set empty → + // never discard a run we simply don't know about yet. + let d = SessionEndPlanner.decide(isRunning: true, previouslyActiveIDs: [], workouts: []) + #expect(d == .none) + } + + @Test func completedSurvivor_isFinish() { + let done = workout("A", .completed) + let d = SessionEndPlanner.decide( + isRunning: true, previouslyActiveIDs: ["A"], workouts: [done]) + #expect(d == .finish(done)) + } + + @Test func vanishedRun_isDiscard() { + // "A" was active, now absent entirely (discarded/deleted on the phone) → discard. + let d = SessionEndPlanner.decide( + isRunning: true, previouslyActiveIDs: ["A"], workouts: []) + #expect(d == .discard) + } + + @Test func stillPartlyActive_isNone() { + // One of two previously-active runs finished, the other is still in progress → the + // active set isn't empty, so the session keeps running. + let d = SessionEndPlanner.decide( + isRunning: true, + previouslyActiveIDs: ["A", "B"], + workouts: [workout("A", .completed), workout("B", .inProgress)]) + #expect(d == .none) + } + + @Test func notRunning_isNone() { + // A cold-relaunched watch with no session must never try to end one. + let d = SessionEndPlanner.decide( + isRunning: false, previouslyActiveIDs: ["A"], workouts: [workout("A", .completed)]) + #expect(d == .none) + } + + @Test func alreadyEnded_isNone() { + // Baseline already empty (we ended on a prior reconcile) — a redelivered completed + // run must not re-trigger the end. + let d = SessionEndPlanner.decide( + isRunning: true, previouslyActiveIDs: [], workouts: [workout("A", .completed)]) + #expect(d == .none) + } + + @Test func skippedOnlySurvivor_isDiscard() { + // A run that resolved entirely to `.skipped` (ended early, nothing completed) is not a + // finish — there's no completed run to save, so the session data is discarded. + let d = SessionEndPlanner.decide( + isRunning: true, previouslyActiveIDs: ["A"], workouts: [workout("A", .skipped)]) + #expect(d == .discard) + } + + // MARK: - activeIDs + + @Test func activeIDs_countsOnlyUnfinished() { + let workouts = [ + workout("A", .notStarted), + workout("B", .inProgress), + workout("C", .completed), + workout("D", .skipped), + ] + #expect(SessionEndPlanner.activeIDs(in: workouts) == ["A", "B"]) + } +}