End the watch workout session from durable state, not a view

The HKWorkoutSession that keeps the watch app foregrounded was ended only
by ActiveWorkoutGateView's onChange(of: activeWorkouts) — a view-level side
effect. When a run ended from the phone while the watch app was
backgrounded (kept alive only by the session) or torn down and rebuilt with
an already-empty list, that onChange never fired: the session leaked (the
app kept re-foregrounding on every wrist raise) and finishAndSave() never
ran, so the HR/energy summary was neither saved to Health nor forwarded.

Move session-end off the view into a long-lived WorkoutSessionCoordinator
owned by WatchAppServices, driven by a new bridge.onWorkoutsChanged
callback fired after every authoritative cache mutation (phone push or the
watch's own optimistic edit). The decision is a pure SessionEndPlanner
seam (mirrors WatchCacheApplier): a running session ends only on a genuine
non-empty -> empty transition of the active set, so the launch race (session
running before the run doc syncs) resolves to .none and never discards a
run we haven't heard about yet. Same move as the live-mirror's
repairFromDurable, one layer down.

Watch-only; no schema or wire change. SessionEndPlannerTests pins the
decision table; the OS-initiated-end path (system ends the session itself)
stays a documented residual in PLAN-watch-session-end.md.
This commit is contained in:
2026-07-09 08:12:06 -04:00
parent 9ea5e54b15
commit 04523c875a
8 changed files with 480 additions and 39 deletions
+2
View File
@@ -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.
+241
View File
@@ -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<String> {
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<String>,
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<String> = []
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<Workout>()))?
.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").
@@ -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)
}
@@ -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.
+20 -1
View File
@@ -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()
}
}
@@ -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<String> {
Set(workouts
.filter {
let status = WorkoutStatus(rawValue: $0.status)
return status == .notStarted || status == .inProgress
}
.map(\.id))
}
static func decide(isRunning: Bool,
previouslyActiveIDs: Set<String>,
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<String> = []
/// 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<Workout>())) ?? [])
.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)
}
}
}
+1 -1
View File
@@ -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) }
}
}
@@ -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"])
}
}