import Foundation import Testing @testable import Workouts_Watch_App /// 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) /// 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, routineID: nil, routineName: nil, start: start, end: nil, status: status.rawValue, createdAt: start, updatedAt: start, 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, sessionAge: 1, previouslyActiveIDs: [], workouts: []) #expect(d == .none) } @Test func completedSurvivor_isFinish() { let done = workout("A", .completed) let d = SessionEndPlanner.decide( 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, sessionAge: Self.matureAge, 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, sessionAge: Self.matureAge, 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, sessionAge: nil, 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, sessionAge: Self.matureAge, 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, 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() { let workouts = [ workout("A", .notStarted), workout("B", .inProgress), workout("C", .completed), workout("D", .skipped), ] #expect(SessionEndPlanner.activeIDs(in: workouts) == ["A", "B"]) } }