diff --git a/BULLETPROOFING.md b/BULLETPROOFING.md index d743ac5..a2a557b 100644 --- a/BULLETPROOFING.md +++ b/BULLETPROOFING.md @@ -21,7 +21,9 @@ Re-verified against code, not just prior docs — these need no work and should ## HIGH — the session lifecycle has a single point of failure -### H1 — The `HKWorkoutSession` can only ever be born via the T1 one-shot · open +### H1 — The `HKWorkoutSession` can only ever be born via the T1 one-shot · **fixed** + +> **Fix (2026-07-09):** the coordinator now self-starts a session on any reconcile that finds an active run with none running (`SessionEndPlanner.shouldStart` / `runToStart`; activity type resolved from the run's split), and `WorkoutSessionManager.recover()` re-adopts a crash/reboot-orphaned session at launch (`recoverActiveWorkoutSession`). T1 is now a fast-launch optimization, not the sole session source. `WorkoutSessionManager.start(with:)` has exactly one caller: `WatchAppDelegate.handle(_:)` (`WatchAppDelegate.swift:31`), i.e. the phone's `startWatchApp(toHandle:)` handoff. That handoff is best-effort `try?` with **no retry** (`WorkoutLauncher.swift:37-40`). There is no `HKHealthStore.recoverActiveWorkoutSession` anywhere, no `WKExtendedRuntimeSession`, and nothing gates run-flow entry on `sessionManager.isRunning`. @@ -33,7 +35,9 @@ Failure scenarios (all real): **Fix direction:** make the watch own its session lifecycle. Self-start a session when entering (or reconciling into) an active run with `isRunning == false`; implement session recovery at launch. T1 then becomes a fast-launch optimization rather than the sole source of sessions. This also absorbs most of H2 and much of the M-tier degradation. -### H2 — "End Current & Start New" swallows the new run's session · open +### H2 — "End Current & Start New" swallows the new run's session · **fixed** + +> **Fix (2026-07-09):** absorbed by H1's self-start (a swallowed T1 heals at the next reconcile), plus a session-age rule in the planner: a `.finish` decided for a session younger than `minimumFinishAge` (30 s) demotes to `.discard`, so the transient "old run completed, new run not yet pushed" context can no longer save a junk seconds-long HKWorkout attributed to the old run — the session is discarded and self-start brings it back when the new run's push lands. `endActiveThenStart` (`WorkoutLogsView.swift:271-281`) runs the end-saves in a `Task` but calls `start(with:)` synchronously — so **T1 goes out while the old run's session is still running on the watch**. `handle(_:)` → `start(with:)` hits the `session == nil` idempotency guard (`WorkoutSessionManager.swift:61`) and is **silently dropped**. Moments later the "A completed" push arrives and the coordinator finishes A's session — leaving run B sessionless (all of H1's fallout). The one-shot was consumed while blocked; nothing replays it. @@ -41,7 +45,9 @@ Timing variant: if B's session *does* start (old one already ended) but the tran **Fix direction:** on the watch, when `handle(_:)` arrives while a session is running, stash the configuration and start it immediately after the current session finishes; and/or a short grace window after `session.startActivity(...)` during which `decide` refuses `.finish`/`.discard`. H1's self-start largely absorbs this too. -### H3 — A system-ended session silently drops the Health save · open +### H3 — A system-ended session silently drops the Health save · **fixed** + +> **Fix (2026-07-09):** `salvageSystemEndedSession` — a system-driven `.ended`/`.stopped` now ends collection and finishes the builder's workout (saving the `HKWorkout` to Health) instead of clearing everything. Residual: the salvaged workout isn't linked to a run document (`healthKitWorkoutUUID` stays nil) and no metrics are forwarded — the runs are still active, so there's no completed run to attach them to. `workoutSession(_:didChangeTo:)` for `.ended`/`.stopped` only calls `clear()` (`WorkoutSessionManager.swift:199-202`) — no `endCollection`/`finishWorkout`, no metrics capture, no forward to the phone. `PLAN-watch-session-end.md` lists this as a known residual. Rare (OS reclaim under resource pressure), but it is a "my workout never made it to Health" support report waiting to happen. diff --git a/CHANGELOG.md b/CHANGELOG.md index e74b534..ce25a70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ **July 2026** +The watch now starts recording on its own whenever a workout is active, so a missed hand-off from the iPhone no longer leaves a session without heart rate or Health credit. + +A workout recording now survives the watch app crashing or the watch restarting, reconnecting to the in-progress session instead of losing it. + +A recording cut short by watchOS itself is now still saved to Apple Health instead of being silently dropped. + +Ending a workout and immediately starting a new one no longer risks a stray seconds-long workout appearing in Apple Health. + A new Cardio exercise and matching starter split let you log aerobic sessions, tracked on your Apple Watch as a cardio workout. A new Auto-Advance option lets a split flow hands-free from one exercise to the next, resting automatically between them. diff --git a/Workouts Watch App/WatchAppDelegate.swift b/Workouts Watch App/WatchAppDelegate.swift index a888479..1c50cae 100644 --- a/Workouts Watch App/WatchAppDelegate.swift +++ b/Workouts Watch App/WatchAppDelegate.swift @@ -25,6 +25,9 @@ final class WatchAppDelegate: NSObject, WKApplicationDelegate { if ScreenshotSeed.isActive { return } #endif sessionManager.requestAuthorization() + // Re-adopt a session that outlived a crash or reboot — watchOS relaunches us + // expecting this call; without it the session is orphaned and its workout lost. + sessionManager.recover() } func handle(_ workoutConfiguration: HKWorkoutConfiguration) { diff --git a/Workouts Watch App/WorkoutSessionCoordinator.swift b/Workouts Watch App/WorkoutSessionCoordinator.swift index d3433b3..2c1031d 100644 --- a/Workouts Watch App/WorkoutSessionCoordinator.swift +++ b/Workouts Watch App/WorkoutSessionCoordinator.swift @@ -6,6 +6,7 @@ // import Foundation +import HealthKit import SwiftData /// What the watch should do with its `HKWorkoutSession` given the latest authoritative @@ -16,17 +17,33 @@ enum SessionEndDecision: Equatable { case finish(WorkoutDocument) } -/// Pure decision seam for ending the watch's workout session — the session-free core the +/// Pure decision seam for the watch's workout-session lifecycle — 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. +/// start/end rules are 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 +/// The end 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. +/// +/// The start rule makes the watch own its session: whenever the authoritative set holds an +/// active run and no session is running, one should be started. The phone's +/// `startWatchApp(toHandle:)` handoff then becomes a fast-launch optimization rather than the +/// only way a session is ever born — a dropped handoff (watch unreachable at start, a handoff +/// swallowed by `start`'s idempotency guard while the old run's session was still running), +/// a crash/reboot, or a run engaged manually on the wrist all heal at the next reconcile. enum SessionEndPlanner { + /// A `.finish` for a session younger than this is demoted to `.discard`: a seconds-old + /// session has no meaningful samples, and the demotion is what keeps the two stale-data + /// races from polluting Apple Health with junk workouts attributed to the wrong run — + /// (a) a transient "old run completed, new run not yet pushed" context arriving right + /// after the new run's session started, and (b) a self-start off stale cache at launch, + /// corrected seconds later by the fresh context. In both cases the active set refills + /// and `shouldStart` brings the session right back. + static let minimumFinishAge: TimeInterval = 30 + /// 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 { @@ -38,7 +55,25 @@ enum SessionEndPlanner { .map(\.id)) } + /// True when a session should be running but isn't — the watch self-starts one rather + /// than depending on the phone's one-shot launch handoff having arrived. + static func shouldStart(isRunning: Bool, workouts: [WorkoutDocument]) -> Bool { + !isRunning && !activeIDs(in: workouts).isEmpty + } + + /// The run whose split configures a self-started session: the most recently started + /// active one (ties broken by id for determinism). + static func runToStart(in workouts: [WorkoutDocument]) -> WorkoutDocument? { + let ids = activeIDs(in: workouts) + return workouts + .filter { ids.contains($0.id) } + .max { a, b in a.start == b.start ? a.id < b.id : a.start < b.start } + } + + /// `sessionAge` is seconds since the running session started (nil when unknown — e.g. a + /// recovered session, which is by definition old enough to finish normally). static func decide(isRunning: Bool, + sessionAge: TimeInterval?, previouslyActiveIDs: Set, workouts: [WorkoutDocument]) -> SessionEndDecision { guard isRunning, @@ -46,19 +81,29 @@ enum SessionEndPlanner { !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. + // Among several completed survivors (parallel runs), take the most recently started — + // deterministic, and the one whose lifetime best matches the session's. let finished = previouslyActiveIDs .compactMap { id in workouts.first { $0.id == id } } - .first { WorkoutStatus(rawValue: $0.status) == .completed } - return finished.map(SessionEndDecision.finish) ?? .discard + .filter { WorkoutStatus(rawValue: $0.status) == .completed } + .max { a, b in a.start == b.start ? a.id < b.id : a.start < b.start } + guard let finished else { return .discard } + // Too young to be a real finish — see `minimumFinishAge`. + if let sessionAge, sessionAge < minimumFinishAge { return .discard } + return .finish(finished) } } -/// 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. +/// The single owner of the watch's workout-session lifecycle — both ends of it. 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. Termination here 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. +/// Self-starting here is the counterpart fix: a session is started whenever the authoritative +/// set holds an active run and none is running, so a dropped phone launch handoff (or a +/// crash, or a run engaged manually on the wrist) can't leave a workout sessionless — no HR, +/// no Health save, app suspending wrist-down. /// /// 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. @@ -91,16 +136,33 @@ final class WorkoutSessionCoordinator { .map(WorkoutDocument.init(from:)) let decision = SessionEndPlanner.decide( isRunning: sessionManager.isRunning, + sessionAge: sessionManager.sessionStartDate.map { -$0.timeIntervalSinceNow }, previouslyActiveIDs: previouslyActiveIDs, workouts: workouts) previouslyActiveIDs = SessionEndPlanner.activeIDs(in: workouts) switch decision { - case .none: return + case .none: startIfNeeded(workouts: workouts) case .discard: sessionManager.discard() case .finish(let doc): endAndForward(doc) } } + /// Self-start a session when an active run has none (see `SessionEndPlanner.shouldStart`). + /// The activity type comes from the run's split so the saved Health workout is categorized + /// the same way a phone-launched one would be; a run whose split is gone (deleted, or not + /// yet synced) falls back to traditional strength training. + private func startIfNeeded(workouts: [WorkoutDocument]) { + guard SessionEndPlanner.shouldStart(isRunning: sessionManager.isRunning, workouts: workouts), + let run = SessionEndPlanner.runToStart(in: workouts) else { return } + let split = run.splitID.flatMap { id in + (try? context.fetch(FetchDescriptor())).flatMap { $0.first { $0.id == id } } + } + let configuration = HKWorkoutConfiguration() + configuration.activityType = split?.activityTypeEnum.hkActivityType ?? .traditionalStrengthTraining + configuration.locationType = .indoor + sessionManager.start(with: configuration) + } + /// 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. diff --git a/Workouts Watch App/WorkoutSessionManager.swift b/Workouts Watch App/WorkoutSessionManager.swift index 9eb72a5..ab69d65 100644 --- a/Workouts Watch App/WorkoutSessionManager.swift +++ b/Workouts Watch App/WorkoutSessionManager.swift @@ -28,6 +28,11 @@ final class WorkoutSessionManager: NSObject { private(set) var isRunning = false + /// When the running session started, for the coordinator's session-age rule (a + /// too-young `.finish` demotes to `.discard`). `distantPast` for a recovered + /// session — it predates this process, so it's never "young". + private(set) var sessionStartDate: Date? + /// Live values for the in-workout HUD. Nil until the first sample arrives (or if /// the corresponding read authorization was denied). private(set) var currentHeartRate: Double? @@ -74,12 +79,45 @@ final class WorkoutSessionManager: NSObject { let startDate = Date() session.startActivity(with: startDate) Task { try? await builder.beginCollection(at: startDate) } + sessionStartDate = startDate isRunning = true } catch { clear() } } + /// Re-adopt a session that outlived a crash or reboot. watchOS keeps the + /// `HKWorkoutSession` alive across an app death and relaunches us expecting a + /// recovery call; without it the session is orphaned — its `HKWorkout` never saved + /// and the live HUD dead. Called once at launch; a no-op when there's nothing to + /// recover or a fresh start already claimed the slot. + func recover() { + guard HKHealthStore.isHealthDataAvailable(), session == nil else { return } + Task { + guard let recovered = try? await healthStore.recoverActiveWorkoutSession(), + self.session == nil else { return } + adopt(recovered) + } + } + + private func adopt(_ recovered: HKWorkoutSession) { + let builder = recovered.associatedWorkoutBuilder() + builder.dataSource = HKLiveWorkoutDataSource( + healthStore: healthStore, workoutConfiguration: recovered.workoutConfiguration) + recovered.delegate = self + builder.delegate = self + self.session = recovered + self.builder = builder + + // Zone seconds accumulated before the crash are gone (they lived in memory); + // resume accumulation from here rather than faking the missing stretch. + resetMetricAccumulators() + maxHeartRate = computeMaxHeartRate() + + sessionStartDate = .distantPast + isRunning = true + } + /// Finish the session and save it to Health, returning the captured metrics so the /// caller can attach them to the workout and forward to the phone. `totalVolume` /// is left nil here — the caller fills it from the workout's logs. @@ -178,11 +216,26 @@ final class WorkoutSessionManager: NSObject { maxHeartRate = nil } + /// The system ended the session out from under us (resource reclaim, watchOS policy). + /// Salvage what the builder collected into a real Health save instead of dropping it — + /// the run documents stay active, so no metrics are forwarded to the phone (there is no + /// completed run to attach them to); the `HKWorkout` itself is what must not be lost. + private func salvageSystemEndedSession(at endDate: Date) { + guard let builder, !isFinishing else { clear(); return } + isFinishing = true + Task { + defer { clear() } + try? await builder.endCollection(at: endDate) + _ = try? await builder.finishWorkout() + } + } + private func clear() { session = nil builder = nil isRunning = false isFinishing = false + sessionStartDate = nil resetMetricAccumulators() } } @@ -197,9 +250,12 @@ extension WorkoutSessionManager: HKWorkoutSessionDelegate { date: Date ) { guard toState == .ended || toState == .stopped else { return } - // Only reset if we didn't drive the end ourselves (finishAndSave/discard set - // `isFinishing` and clear on their own); this catches system-ended sessions. - Task { @MainActor in if !self.isFinishing { self.clear() } } + // Only act if we didn't drive the end ourselves (finishAndSave/discard set + // `isFinishing` and clear on their own). A system-ended session is salvaged + // into a Health save rather than silently dropped. + Task { @MainActor in + if !self.isFinishing { self.salvageSystemEndedSession(at: date) } + } } nonisolated func workoutSession(_ workoutSession: HKWorkoutSession, didFailWithError error: Error) { diff --git a/Workouts Watch AppTests/SessionEndPlannerTests.swift b/Workouts Watch AppTests/SessionEndPlannerTests.swift index 1942d83..efecdec 100644 --- a/Workouts Watch AppTests/SessionEndPlannerTests.swift +++ b/Workouts Watch AppTests/SessionEndPlannerTests.swift @@ -2,20 +2,25 @@ 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. +/// Pins the pure session-lifecycle decisions (`SessionEndPlanner`) — the rules that start and +/// end the watch's `HKWorkoutSession` from the authoritative workout set instead of a view +/// observer or the phone's one-shot launch handoff. 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; 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; a too-young `.finish` demotes to `.discard` so a stale-data race can't save a junk +/// workout to Health; and a session should start whenever an active run has none. struct SessionEndPlannerTests { private static let ts = Date(timeIntervalSince1970: 1_700_000_000) - private func workout(_ id: String, _ status: WorkoutStatus) -> WorkoutDocument { + /// Comfortably past `minimumFinishAge` — the "normal running session" age. + private static let matureAge: TimeInterval = 3600 + + private func workout(_ id: String, _ status: WorkoutStatus, start: Date = ts) -> 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, + id: id, splitID: nil, splitName: nil, start: start, end: nil, + status: status.rawValue, createdAt: start, updatedAt: start, logs: [], metrics: nil) } @@ -24,21 +29,24 @@ struct SessionEndPlannerTests { @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: []) + let d = SessionEndPlanner.decide( + isRunning: true, sessionAge: 1, previouslyActiveIDs: [], workouts: []) #expect(d == .none) } @Test func completedSurvivor_isFinish() { let done = workout("A", .completed) let d = SessionEndPlanner.decide( - isRunning: true, previouslyActiveIDs: ["A"], workouts: [done]) + isRunning: true, sessionAge: Self.matureAge, + 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: []) + isRunning: true, sessionAge: Self.matureAge, + previouslyActiveIDs: ["A"], workouts: []) #expect(d == .discard) } @@ -46,7 +54,7 @@ struct SessionEndPlannerTests { // 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, + isRunning: true, sessionAge: Self.matureAge, previouslyActiveIDs: ["A", "B"], workouts: [workout("A", .completed), workout("B", .inProgress)]) #expect(d == .none) @@ -55,7 +63,8 @@ struct SessionEndPlannerTests { @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)]) + isRunning: false, sessionAge: nil, + previouslyActiveIDs: ["A"], workouts: [workout("A", .completed)]) #expect(d == .none) } @@ -63,7 +72,8 @@ struct SessionEndPlannerTests { // 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)]) + isRunning: true, sessionAge: Self.matureAge, + previouslyActiveIDs: [], workouts: [workout("A", .completed)]) #expect(d == .none) } @@ -71,10 +81,82 @@ struct SessionEndPlannerTests { // 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)]) + isRunning: true, sessionAge: Self.matureAge, + previouslyActiveIDs: ["A"], workouts: [workout("A", .skipped)]) #expect(d == .discard) } + // MARK: - decide, session-age demotion + + @Test func youngFinish_demotesToDiscard() { + // "End Current & Start New": the new run's session just started when the transient + // "old run completed, new run not yet pushed" context applies. A finish here would + // save a seconds-long junk workout to Health attributed to the OLD run — demote to + // discard; `shouldStart` restarts the session when the new run's push lands. + let d = SessionEndPlanner.decide( + isRunning: true, sessionAge: 2, + previouslyActiveIDs: ["A"], workouts: [workout("A", .completed)]) + #expect(d == .discard) + } + + @Test func matureFinish_staysFinish() { + let done = workout("A", .completed) + let d = SessionEndPlanner.decide( + isRunning: true, sessionAge: SessionEndPlanner.minimumFinishAge, + previouslyActiveIDs: ["A"], workouts: [done]) + #expect(d == .finish(done)) + } + + @Test func unknownAge_staysFinish() { + // A recovered session has no in-process start date — it predates this process, so + // it's never "young" and finishes normally. + let done = workout("A", .completed) + let d = SessionEndPlanner.decide( + isRunning: true, sessionAge: nil, + previouslyActiveIDs: ["A"], workouts: [done]) + #expect(d == .finish(done)) + } + + @Test func parallelCompletions_pickMostRecentlyStarted() { + // Two parallel runs completing together: the finish deterministically attaches the + // session's metrics to the most recently started one, not Set-iteration luck. + let older = workout("A", .completed, start: Self.ts) + let newer = workout("B", .completed, start: Self.ts.addingTimeInterval(600)) + let d = SessionEndPlanner.decide( + isRunning: true, sessionAge: Self.matureAge, + previouslyActiveIDs: ["A", "B"], workouts: [older, newer]) + #expect(d == .finish(newer)) + } + + // MARK: - shouldStart / runToStart + + @Test func activeRunWithoutSession_shouldStart() { + // The self-start rule: an active run with no session (dropped launch handoff, manual + // engagement, post-crash) gets one at the next reconcile. + #expect(SessionEndPlanner.shouldStart( + isRunning: false, workouts: [workout("A", .inProgress)])) + } + + @Test func sessionAlreadyRunning_shouldNotStart() { + #expect(!SessionEndPlanner.shouldStart( + isRunning: true, workouts: [workout("A", .inProgress)])) + } + + @Test func nothingActive_shouldNotStart() { + // Completed/skipped runs (or an empty cache) must never spin up a session. + #expect(!SessionEndPlanner.shouldStart( + isRunning: false, + workouts: [workout("A", .completed), workout("B", .skipped)])) + #expect(!SessionEndPlanner.shouldStart(isRunning: false, workouts: [])) + } + + @Test func runToStart_isMostRecentlyStartedActive() { + let older = workout("A", .inProgress, start: Self.ts) + let newer = workout("B", .notStarted, start: Self.ts.addingTimeInterval(600)) + let done = workout("C", .completed, start: Self.ts.addingTimeInterval(1200)) + #expect(SessionEndPlanner.runToStart(in: [older, newer, done]) == newer) + } + // MARK: - activeIDs @Test func activeIDs_countsOnlyUnfinished() {