Make the watch own its workout-session lifecycle
The HKWorkoutSession could only ever be born via the phone's one-shot startWatchApp handoff (BULLETPROOFING.md H1-H3): a dropped handoff, a watch crash/reboot, or a run engaged manually on the wrist left the whole workout sessionless - no heart rate, no Health save, app suspending wrist-down - and "End Current & Start New" swallowed the handoff against the old session's idempotency guard. Now the coordinator self-starts a session on any reconcile that finds an active run with none running (SessionEndPlanner.shouldStart / runToStart, activity type from the run's split), recover() re-adopts a crash-orphaned session at launch, and a system-ended session salvages its Health save instead of dropping it. A .finish decided for a session younger than 30s demotes to .discard so the stale-context races can't save junk workouts attributed to the wrong run; parallel completions now pick the survivor deterministically. Claude-Session: https://claude.ai/code/session_01PVNBVKp5bcq52X722uMjwT
This commit is contained in:
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user