Files
workouts/Workouts Watch AppTests/SessionEndPlannerTests.swift
T
rzen 04523c875a 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.
2026-07-09 08:12:06 -04:00

90 lines
3.7 KiB
Swift

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