L'en-tête affirmait que `HKLiveWorkoutBuilder.elapsedTime` « exclut déjà les pauses », et en tirait que le moteur n'avait rien à savoir de la pause ni de l'auto-pause. La doc Apple dit le contraire, vérifié à la source le 2026-08-20 : « The elapsed time for the workout based on the builder's current contents, including pauses. » Conséquence si on câblait le moteur dessus telle quelle : une pause de 5 min ferait avancer le déroulé de 5 min d'effort — un fractionné mis en pause pour traverser une route se déroulerait à l'arrêt. La propriété qui exclut réellement les pauses est `HKWorkoutBuilder.elapsedTime(at:)` : « The duration of a workout doesn't include intervals between pause and resume events. » Les deux textes d'Apple se contredisent frontalement ; le choix de la source de temps reste à faire avant le câblage. Le moteur lui-même reste correct : il n'intègre que ce qu'on lui pousse. Aucun comportement modifié, 57 tests Swift toujours verts. COWORK gagne l'analyse complète du câblage (3 couches, le serveur sait déjà produire les étapes via _blocks_from_session) pour reprise à froid. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
384 lines
15 KiB
Swift
384 lines
15 KiB
Swift
import Foundation
|
||
|
||
/* Déroulé d'une séance structurée au poignet.
|
||
*
|
||
* **Pourquoi ce fichier existe.** WorkoutKit ne sait pas exécuter une séance
|
||
* dans une app tierce : son seul point d'exécution public est
|
||
* `WorkoutPlan.openInWorkoutApp()`, qui ouvre l'app *Exercice* d'Apple. Pour
|
||
* guider les intervalles du plan coach dans NOTRE app, la machine à états doit
|
||
* être écrite ici.
|
||
*
|
||
* Le moteur est **volontairement pur** — aucun import HealthKit, aucun timer,
|
||
* aucune horloge interne. Il ne fait que répondre à la question « où en
|
||
* sommes-nous ? » à partir du temps et de la distance que l'appelant lui
|
||
* pousse. Trois conséquences :
|
||
*
|
||
* - il se teste intégralement sur Linux (cf. `tests-linux/`), là où
|
||
* `WorkoutManager` ne le peut pas ;
|
||
* - le temps de référence est poussé par l'appelant ;
|
||
*
|
||
* ⚠️ **CE COMMENTAIRE AFFIRMAIT LE CONTRAIRE DE SA SOURCE** (relevé le
|
||
* 2026-08-20, avant tout câblage). Il disait que
|
||
* `HKLiveWorkoutBuilder.elapsedTime` « exclut déjà les pauses », donc que
|
||
* le moteur n'avait rien à savoir de la pause. La doc Apple dit
|
||
* l'inverse, mot pour mot : « The elapsed time for the workout based on
|
||
* the builder's current contents, **including pauses**. »
|
||
* (developer.apple.com/documentation/healthkit/hkliveworkoutbuilder/elapsedtime)
|
||
*
|
||
* Conséquence si on câble le moteur sur cette propriété telle quelle :
|
||
* une pause de 5 min ferait avancer le déroulé de 5 min d'effort. Un
|
||
* fractionné mis en pause pour traverser une route se déroulerait tout
|
||
* seul, à l'arrêt.
|
||
*
|
||
* La propriété qui exclut réellement les pauses est
|
||
* `HKWorkoutBuilder.elapsedTime(at:)` — « The duration of a workout
|
||
* doesn't include intervals between pause and resume events. » Ce n'est
|
||
* PAS la même API, et les deux textes d'Apple se contredisent
|
||
* frontalement sur ce point : à trancher avant le câblage.
|
||
*
|
||
* Le moteur, lui, reste correct : il ne fait qu'intégrer ce qu'on lui
|
||
* pousse. C'est l'appelant qui devra fournir un temps réellement actif ;
|
||
*
|
||
* - il ignore donc l'auto-pause de la montre, à condition que la source de
|
||
* temps ci-dessus soit correcte ;
|
||
* - il est rejouable : réinjecter la même suite de ticks redonne le même
|
||
* déroulé, ce qui rend la reprise après crash triviale
|
||
* (`handleActiveWorkoutRecovery`).
|
||
*/
|
||
|
||
// MARK: - Modèle
|
||
|
||
/// Objectif de fin d'une étape.
|
||
///
|
||
/// `open` décrit une étape qui ne se termine que sur ordre de l'utilisateur —
|
||
/// l'échauffement libre « pars quand tu es prêt », ou le retour au calme.
|
||
public enum StepGoal: Equatable, Sendable {
|
||
case time(TimeInterval)
|
||
case distance(Double) // mètres
|
||
case open
|
||
}
|
||
|
||
/// Une étape du plan, telle qu'elle arrive de l'iPhone.
|
||
///
|
||
/// Les bornes de FC sont transportées **en bpm ET en numéro de zone**. Les deux
|
||
/// sont nécessaires : la zone sert à l'affichage (« Z2 »), les bpm sont la seule
|
||
/// cible exécutable, et elles proviennent de `app.current_zones()` — zones
|
||
/// Karvonen sous bêtabloquant, jamais un % de FCmax générique.
|
||
public struct CoachPlanStep: Codable, Equatable, Sendable {
|
||
public enum Kind: String, Codable, Sendable {
|
||
case warmup, work, recovery, cooldown
|
||
}
|
||
|
||
public var kind: Kind
|
||
public var durationSec: Double?
|
||
public var distanceM: Double?
|
||
public var hrZone: Int?
|
||
public var hrMinBpm: Double?
|
||
public var hrMaxBpm: Double?
|
||
public var label: String?
|
||
|
||
enum CodingKeys: String, CodingKey {
|
||
case kind
|
||
case durationSec = "duration_sec"
|
||
case distanceM = "distance_m"
|
||
case hrZone = "hr_zone"
|
||
case hrMinBpm = "hr_bpm_min"
|
||
case hrMaxBpm = "hr_bpm_max"
|
||
case label
|
||
}
|
||
|
||
public init(kind: Kind,
|
||
durationSec: Double? = nil,
|
||
distanceM: Double? = nil,
|
||
hrZone: Int? = nil,
|
||
hrMinBpm: Double? = nil,
|
||
hrMaxBpm: Double? = nil,
|
||
label: String? = nil) {
|
||
self.kind = kind
|
||
self.durationSec = durationSec
|
||
self.distanceM = distanceM
|
||
self.hrZone = hrZone
|
||
self.hrMinBpm = hrMinBpm
|
||
self.hrMaxBpm = hrMaxBpm
|
||
self.label = label
|
||
}
|
||
|
||
/// La durée prime sur la distance quand les deux sont fournies : le plan
|
||
/// coach est écrit en temps, la distance n'est qu'une alternative.
|
||
public var goal: StepGoal {
|
||
if let d = durationSec, d > 0 { return .time(d) }
|
||
if let m = distanceM, m > 0 { return .distance(m) }
|
||
return .open
|
||
}
|
||
|
||
/// Libellé affichable, avec repli sur le type d'étape. Le plan porte les
|
||
/// consignes réelles dans `label` (« cadence 170+, foulée courte ») : c'est
|
||
/// cette intention qu'on veut au poignet, pas un « Course » générique.
|
||
public var displayLabel: String {
|
||
if let l = label?.trimmingCharacters(in: .whitespacesAndNewlines), !l.isEmpty {
|
||
return l
|
||
}
|
||
switch kind {
|
||
case .warmup: return "Échauffement"
|
||
case .work: return "Effort"
|
||
case .recovery: return "Récupération"
|
||
case .cooldown: return "Retour au calme"
|
||
}
|
||
}
|
||
}
|
||
|
||
/// La séance du jour, poussée par l'iPhone.
|
||
public struct CoachSessionPlan: Codable, Equatable, Sendable {
|
||
public var date: String
|
||
public var sport: String
|
||
public var title: String?
|
||
public var steps: [CoachPlanStep]
|
||
|
||
enum CodingKeys: String, CodingKey {
|
||
case date, sport, title, steps
|
||
}
|
||
|
||
public init(date: String, sport: String, title: String? = nil, steps: [CoachPlanStep]) {
|
||
self.date = date
|
||
self.sport = sport
|
||
self.title = title
|
||
self.steps = steps
|
||
}
|
||
|
||
/// Durée totale prévue. `nil` dès qu'une étape est ouverte ou en distance :
|
||
/// mieux vaut ne rien annoncer qu'annoncer un total faux.
|
||
public var plannedDurationSec: TimeInterval? {
|
||
var total: TimeInterval = 0
|
||
for step in steps {
|
||
guard case .time(let d) = step.goal else { return nil }
|
||
total += d
|
||
}
|
||
return total
|
||
}
|
||
}
|
||
|
||
// MARK: - Moteur
|
||
|
||
/// Ce que le moteur signale à l'appelant entre deux ticks.
|
||
///
|
||
/// Les événements sont **rendus, jamais joués** par le moteur : c'est
|
||
/// `WorkoutManager` qui décide d'un haptique ou d'un `HKWorkoutEvent`. Cette
|
||
/// séparation est ce qui garde le moteur testable.
|
||
public enum IntervalEvent: Equatable, Sendable {
|
||
case stepStarted(index: Int)
|
||
case stepFinished(index: Int)
|
||
case planFinished
|
||
/// Émis une seule fois par étape, à mi-parcours d'une étape mesurable.
|
||
case halfway(index: Int)
|
||
|
||
/// Vrai si l'événement fait changer d'étape.
|
||
///
|
||
/// L'annonce de mi-parcours voyage dans le même flux que les transitions —
|
||
/// pratique pour l'appelant, qui n'a qu'une boucle à écrire — mais elle ne
|
||
/// déplace rien. Sans ce distinguo, « rien n'a bougé » et « aucun événement »
|
||
/// se confondent, et c'est exactement l'erreur que les premiers tests de ce
|
||
/// fichier ont commise.
|
||
public var isTransition: Bool {
|
||
switch self {
|
||
case .stepStarted, .stepFinished, .planFinished: return true
|
||
case .halfway: return false
|
||
}
|
||
}
|
||
}
|
||
|
||
extension Array where Element == IntervalEvent {
|
||
/// Les seuls événements qui déplacent le curseur de séance.
|
||
public var transitions: [IntervalEvent] { filter(\.isTransition) }
|
||
}
|
||
|
||
/// Instantané de progression, destiné à l'affichage.
|
||
public struct IntervalProgress: Equatable, Sendable {
|
||
public var stepIndex: Int
|
||
public var step: CoachPlanStep?
|
||
public var nextStep: CoachPlanStep?
|
||
/// Temps passé dans l'étape courante (secondes actives).
|
||
public var elapsedInStep: TimeInterval
|
||
/// Temps restant, `nil` pour une étape ouverte ou en distance.
|
||
public var remainingInStep: TimeInterval?
|
||
/// Distance restante en mètres, `nil` si l'étape n'est pas en distance.
|
||
public var remainingDistanceM: Double?
|
||
/// 0…1, `nil` pour une étape ouverte.
|
||
public var fraction: Double?
|
||
public var isFinished: Bool
|
||
}
|
||
|
||
/// Machine à états du déroulé. `struct` mutable : pas d'état caché, copiable,
|
||
/// donc inspectable dans les tests.
|
||
public struct IntervalEngine: Equatable, Sendable {
|
||
public let steps: [CoachPlanStep]
|
||
|
||
public private(set) var index: Int = 0
|
||
public private(set) var isFinished: Bool = false
|
||
|
||
/// Origine de l'étape courante, dans le référentiel du builder.
|
||
private var stepStartElapsed: TimeInterval = 0
|
||
private var stepStartDistance: Double = 0
|
||
private var lastElapsed: TimeInterval = 0
|
||
private var lastDistance: Double = 0
|
||
private var halfwayAnnounced = false
|
||
|
||
public init(steps: [CoachPlanStep]) {
|
||
self.steps = steps
|
||
self.isFinished = steps.isEmpty
|
||
}
|
||
|
||
public init(plan: CoachSessionPlan) {
|
||
self.init(steps: plan.steps)
|
||
}
|
||
|
||
public var currentStep: CoachPlanStep? {
|
||
guard !isFinished, steps.indices.contains(index) else { return nil }
|
||
return steps[index]
|
||
}
|
||
|
||
public var nextStep: CoachPlanStep? {
|
||
let n = index + 1
|
||
guard !isFinished, steps.indices.contains(n) else { return nil }
|
||
return steps[n]
|
||
}
|
||
|
||
/// Pousse l'état de la séance et récupère les transitions franchies.
|
||
///
|
||
/// `elapsed` est le temps **actif** (`HKLiveWorkoutBuilder.elapsedTime`),
|
||
/// `distance` la distance cumulée en mètres depuis le départ.
|
||
///
|
||
/// ⚠️ Une boucle, pas un `if` : un tick peut arriver en retard — l'app est
|
||
/// suspendue poignet baissé, la collecte HealthKit est irrégulière — et
|
||
/// franchir **plusieurs** étapes courtes d'un coup. Traiter une seule
|
||
/// transition par tick ferait dériver le déroulé sans que rien ne le dise.
|
||
@discardableResult
|
||
public mutating func update(elapsed: TimeInterval, distance: Double) -> [IntervalEvent] {
|
||
guard !isFinished else { return [] }
|
||
|
||
// Le temps actif ne recule pas ; la distance non plus. Un recul signale
|
||
// un appelant fautif : on borne plutôt que de produire des durées
|
||
// négatives qui se propageraient dans l'affichage.
|
||
lastElapsed = max(elapsed, lastElapsed)
|
||
lastDistance = max(distance, lastDistance)
|
||
|
||
var events: [IntervalEvent] = []
|
||
if !hasStarted {
|
||
hasStarted = true
|
||
events.append(.stepStarted(index: 0))
|
||
}
|
||
|
||
while !isFinished, let step = currentStep, isComplete(step) {
|
||
events.append(.stepFinished(index: index))
|
||
advanceIndex(at: lastElapsed, distance: lastDistance)
|
||
if isFinished {
|
||
events.append(.planFinished)
|
||
} else {
|
||
events.append(.stepStarted(index: index))
|
||
}
|
||
}
|
||
|
||
if !halfwayAnnounced, let f = progress().fraction, f >= 0.5, !isFinished {
|
||
halfwayAnnounced = true
|
||
events.append(.halfway(index: index))
|
||
}
|
||
|
||
return events
|
||
}
|
||
|
||
/// Termine l'étape courante sur ordre de l'utilisateur (bouton « suivant »,
|
||
/// ou fin d'une étape ouverte).
|
||
@discardableResult
|
||
public mutating func advanceManually() -> [IntervalEvent] {
|
||
guard !isFinished, currentStep != nil else { return [] }
|
||
var events: [IntervalEvent] = [.stepFinished(index: index)]
|
||
advanceIndex(at: lastElapsed, distance: lastDistance)
|
||
events.append(isFinished ? .planFinished : .stepStarted(index: index))
|
||
return events
|
||
}
|
||
|
||
public func progress() -> IntervalProgress {
|
||
guard !isFinished, let step = currentStep else {
|
||
return IntervalProgress(stepIndex: index, step: nil, nextStep: nil,
|
||
elapsedInStep: 0, remainingInStep: nil,
|
||
remainingDistanceM: nil, fraction: nil,
|
||
isFinished: true)
|
||
}
|
||
let inStep = max(0, lastElapsed - stepStartElapsed)
|
||
var remaining: TimeInterval?
|
||
var remainingDistance: Double?
|
||
var fraction: Double?
|
||
|
||
switch step.goal {
|
||
case .time(let target):
|
||
remaining = max(0, target - inStep)
|
||
fraction = target > 0 ? min(1, inStep / target) : nil
|
||
case .distance(let target):
|
||
let done = max(0, lastDistance - stepStartDistance)
|
||
remainingDistance = max(0, target - done)
|
||
fraction = target > 0 ? min(1, done / target) : nil
|
||
case .open:
|
||
break
|
||
}
|
||
|
||
return IntervalProgress(stepIndex: index,
|
||
step: step,
|
||
nextStep: nextStep,
|
||
elapsedInStep: inStep,
|
||
remainingInStep: remaining,
|
||
remainingDistanceM: remainingDistance,
|
||
fraction: fraction,
|
||
isFinished: false)
|
||
}
|
||
|
||
// MARK: Privé
|
||
|
||
private var hasStarted = false
|
||
|
||
private func isComplete(_ step: CoachPlanStep) -> Bool {
|
||
switch step.goal {
|
||
case .time(let target):
|
||
return (lastElapsed - stepStartElapsed) >= target
|
||
case .distance(let target):
|
||
return (lastDistance - stepStartDistance) >= target
|
||
case .open:
|
||
// Une étape ouverte ne se termine JAMAIS toute seule : seul
|
||
// `advanceManually()` la clôt. Sinon la séance défilerait d'un coup.
|
||
return false
|
||
}
|
||
}
|
||
|
||
/// Fait démarrer l'étape suivante à la frontière **théorique** de l'étape
|
||
/// qui vient de finir, pas à l'instant du tick.
|
||
///
|
||
/// ⚠️ C'est la subtilité de tout ce fichier. Si un tick arrive 3 s après la
|
||
/// fin d'un intervalle d'une minute, caler le départ de la suivante sur ce
|
||
/// tick lui offre 3 s de rab — et l'erreur **s'accumule** à chaque
|
||
/// répétition. Sur un 9×(1'/1'), le décalage final se compte en dizaines de
|
||
/// secondes.
|
||
private mutating func advanceIndex(at elapsed: TimeInterval, distance: Double) {
|
||
if let step = currentStep {
|
||
switch step.goal {
|
||
case .time(let target):
|
||
stepStartElapsed += target
|
||
stepStartDistance = distance
|
||
case .distance(let target):
|
||
stepStartDistance += target
|
||
stepStartElapsed = elapsed
|
||
case .open:
|
||
stepStartElapsed = elapsed
|
||
stepStartDistance = distance
|
||
}
|
||
}
|
||
// Une étape ouverte ou terminée à la main ne peut pas laisser l'origine
|
||
// derrière le tick courant.
|
||
stepStartElapsed = min(stepStartElapsed, elapsed)
|
||
stepStartDistance = min(stepStartDistance, distance)
|
||
|
||
index += 1
|
||
halfwayAnnounced = false
|
||
if index >= steps.count {
|
||
isFinished = true
|
||
}
|
||
}
|
||
}
|