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"]) } }