From f2fa3cccb0a5b61f9acbd96aa3d9f6df2885cb8b Mon Sep 17 00:00:00 2001 From: Sylvain Bettinelli Date: Thu, 20 Aug 2026 07:50:27 +0000 Subject: [PATCH] =?UTF-8?q?Le=20moteur=20d'intervalles,=20pour=20guider=20?= =?UTF-8?q?une=20s=C3=A9ance=20dans=20notre=20app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WorkoutKit ne sait pas exécuter une séance structurée dans une app tierce : son seul point d'exécution public est `WorkoutPlan.openInWorkoutApp()`, qui ouvre l'app Exercice d'Apple. Guider les intervalles du plan coach au poignet impose donc d'écrire la machine à états nous-mêmes. `IntervalEngine` est volontairement pur — aucun HealthKit, aucun timer, aucune horloge interne. Il répond à « où en sommes-nous ? » à partir du temps et de la distance qu'on lui pousse. Le temps de référence est `elapsedTime` du builder, qui exclut déjà les pauses : le moteur n'a rien à savoir de la pause ni de l'auto-pause. Et comme il est une fonction de la suite des ticks, rejouer celle-ci redonne le même déroulé — ce qui rend la reprise après crash triviale. Le point délicat est la dérive. Quand un tick arrive en retard (app suspendue poignet baissé, collecte irrégulière), l'étape suivante démarre à la frontière THÉORIQUE de la précédente, jamais à l'instant du tick. Sinon chaque répétition offre quelques secondes de rab et l'erreur s'accumule : sur un 9×(1'/1'), le décalage final se compte en dizaines de secondes. Pour la même raison `update` boucle au lieu de tester une fois — un tick manqué peut franchir plusieurs étapes courtes d'un coup. 17 tests, dont 5 vérifiés rouges en neutralisant les frontières théoriques. Rien n'est encore câblé : le fichier n'est pas membre de la cible CoachWatch. Co-Authored-By: Claude Opus 5 (1M context) --- ios/App/CoachWatch/IntervalEngine.swift | 360 ++++++++++++++++++ .../Sources/CoachModel/IntervalEngine.swift | 1 + .../CoachModelTests/IntervalEngineTests.swift | 267 +++++++++++++ 3 files changed, 628 insertions(+) create mode 100644 ios/App/CoachWatch/IntervalEngine.swift create mode 120000 tests-linux/Sources/CoachModel/IntervalEngine.swift create mode 100644 tests-linux/Tests/CoachModelTests/IntervalEngineTests.swift diff --git a/ios/App/CoachWatch/IntervalEngine.swift b/ios/App/CoachWatch/IntervalEngine.swift new file mode 100644 index 0000000..81445d4 --- /dev/null +++ b/ios/App/CoachWatch/IntervalEngine.swift @@ -0,0 +1,360 @@ +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 `HKLiveWorkoutBuilder.elapsedTime`, qui + * **exclut déjà les pauses** : le moteur n'a donc rien à savoir de la + * pause, ni de l'auto-pause de la montre ; + * - 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 + } + } +} diff --git a/tests-linux/Sources/CoachModel/IntervalEngine.swift b/tests-linux/Sources/CoachModel/IntervalEngine.swift new file mode 120000 index 0000000..3586775 --- /dev/null +++ b/tests-linux/Sources/CoachModel/IntervalEngine.swift @@ -0,0 +1 @@ +../../../ios/App/CoachWatch/IntervalEngine.swift \ No newline at end of file diff --git a/tests-linux/Tests/CoachModelTests/IntervalEngineTests.swift b/tests-linux/Tests/CoachModelTests/IntervalEngineTests.swift new file mode 100644 index 0000000..26ca5c3 --- /dev/null +++ b/tests-linux/Tests/CoachModelTests/IntervalEngineTests.swift @@ -0,0 +1,267 @@ +import XCTest +@testable import CoachModel + +/* Le déroulé d'une séance structurée au poignet. + * + * Ces tests portent sur ce qu'aucun build Xcode ne rattrape : la dérive + * silencieuse. Un intervalle qui démarre trois secondes trop tard ne casse + * rien, ne lève rien, et se voit seulement au neuvième tour — sur le terrain, + * pas en relecture. + */ +final class IntervalEngineTests: XCTestCase { + + // Séance réelle du plan (CDC · reprise) : 5' marche, 3×(1' course / 1' + // marche), 5' marche. C'est le gabarit le plus fréquent chez l'utilisateur. + private func cdcPlan(reps: Int = 3) -> [CoachPlanStep] { + var steps: [CoachPlanStep] = [ + CoachPlanStep(kind: .warmup, durationSec: 300, hrZone: 1, label: "5 min marche") + ] + for _ in 0..