Le moteur d'intervalles, pour guider une séance dans notre app
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) <noreply@anthropic.com>
This commit is contained in:
1
tests-linux/Sources/CoachModel/IntervalEngine.swift
Symbolic link
1
tests-linux/Sources/CoachModel/IntervalEngine.swift
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../ios/App/CoachWatch/IntervalEngine.swift
|
||||
267
tests-linux/Tests/CoachModelTests/IntervalEngineTests.swift
Normal file
267
tests-linux/Tests/CoachModelTests/IntervalEngineTests.swift
Normal file
@@ -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..<reps {
|
||||
steps.append(CoachPlanStep(kind: .work, durationSec: 60, hrZone: 2,
|
||||
label: "1' course, cadence 170+"))
|
||||
steps.append(CoachPlanStep(kind: .recovery, durationSec: 60, hrZone: 1,
|
||||
label: "1' marche"))
|
||||
}
|
||||
steps.append(CoachPlanStep(kind: .cooldown, durationSec: 300, hrZone: 1,
|
||||
label: "5 min marche"))
|
||||
return steps
|
||||
}
|
||||
|
||||
// MARK: Déroulé nominal
|
||||
|
||||
func testFirstStepIsAnnouncedOnFirstTick() {
|
||||
var engine = IntervalEngine(steps: cdcPlan())
|
||||
let events = engine.update(elapsed: 0, distance: 0)
|
||||
XCTAssertEqual(events, [.stepStarted(index: 0)])
|
||||
XCTAssertEqual(engine.index, 0)
|
||||
XCTAssertEqual(engine.currentStep?.label, "5 min marche")
|
||||
XCTAssertEqual(engine.nextStep?.kind, .work)
|
||||
}
|
||||
|
||||
func testStepBoundaryEmitsFinishThenStart() {
|
||||
var engine = IntervalEngine(steps: cdcPlan())
|
||||
engine.update(elapsed: 0, distance: 0)
|
||||
XCTAssertTrue(engine.update(elapsed: 299, distance: 0).transitions.isEmpty,
|
||||
"Une seconde avant la fin, rien ne doit basculer")
|
||||
|
||||
let events = engine.update(elapsed: 300, distance: 0)
|
||||
XCTAssertEqual(events, [.stepFinished(index: 0), .stepStarted(index: 1)])
|
||||
XCTAssertEqual(engine.index, 1)
|
||||
}
|
||||
|
||||
func testWholeSessionRunsToCompletion() {
|
||||
var engine = IntervalEngine(steps: cdcPlan())
|
||||
var finished = false
|
||||
// 16 min de séance échantillonnées à la seconde.
|
||||
for t in stride(from: 0.0, through: 960.0, by: 1.0) {
|
||||
if engine.update(elapsed: t, distance: 0).contains(.planFinished) {
|
||||
finished = true
|
||||
XCTAssertEqual(t, 960, "5 min + 3×(1'+1') + 5 min = 16 min = 960 s")
|
||||
break
|
||||
}
|
||||
}
|
||||
XCTAssertTrue(finished)
|
||||
XCTAssertTrue(engine.isFinished)
|
||||
XCTAssertNil(engine.currentStep)
|
||||
}
|
||||
|
||||
// MARK: La dérive — le cœur du sujet
|
||||
|
||||
func testLateTickDoesNotGrantExtraTimeToTheNextStep() {
|
||||
// L'app est suspendue poignet baissé : le tick suivant arrive 3 s en
|
||||
// retard. L'intervalle d'une minute qui démarre ne doit PAS hériter de
|
||||
// ces 3 secondes.
|
||||
var engine = IntervalEngine(steps: cdcPlan())
|
||||
engine.update(elapsed: 0, distance: 0)
|
||||
engine.update(elapsed: 303, distance: 0) // 3 s de retard sur la frontière
|
||||
|
||||
XCTAssertEqual(engine.index, 1)
|
||||
XCTAssertEqual(engine.progress().elapsedInStep, 3, accuracy: 0.001,
|
||||
"L'étape a commencé à 300 s, pas à 303 s")
|
||||
XCTAssertEqual(engine.progress().remainingInStep ?? -1, 57, accuracy: 0.001)
|
||||
|
||||
// Et la frontière suivante tombe bien à 360 s, pas à 363 s.
|
||||
XCTAssertTrue(engine.update(elapsed: 359, distance: 0).transitions.isEmpty)
|
||||
XCTAssertEqual(engine.update(elapsed: 360, distance: 0),
|
||||
[.stepFinished(index: 1), .stepStarted(index: 2)])
|
||||
}
|
||||
|
||||
func testNoCumulativeDriftOverManyRepetitions() {
|
||||
// Neuf répétitions, chaque tick arrivant systématiquement en retard de
|
||||
// 2,5 s. Sans frontières théoriques, l'erreur s'accumulerait à ~22 s.
|
||||
var engine = IntervalEngine(steps: cdcPlan(reps: 9))
|
||||
var t = 0.0
|
||||
var lastStart: TimeInterval = 0
|
||||
var events: [IntervalEvent] = []
|
||||
while !engine.isFinished && t < 3000 {
|
||||
events = engine.update(elapsed: t, distance: 0)
|
||||
if events.contains(where: { if case .stepStarted = $0 { return true }; return false }) {
|
||||
lastStart = t
|
||||
}
|
||||
t += 2.5
|
||||
}
|
||||
XCTAssertTrue(engine.isFinished)
|
||||
// Dernière étape (retour au calme) : démarre à 1380 s en théorie.
|
||||
// Avec des ticks tous les 2,5 s, on la détecte au plus tard 2,5 s après.
|
||||
XCTAssertEqual(lastStart, 1380, accuracy: 2.5,
|
||||
"La fin de séance ne doit pas dériver avec les répétitions")
|
||||
}
|
||||
|
||||
func testSeveralShortStepsCrossedInASingleTick() {
|
||||
// Tick manqué de 30 s sur des intervalles de 10 s : trois étapes
|
||||
// franchies d'un coup. Traiter une seule transition par tick ferait
|
||||
// décrocher le déroulé sans rien signaler.
|
||||
let steps = (0..<6).map { i in
|
||||
CoachPlanStep(kind: i.isMultiple(of: 2) ? .work : .recovery,
|
||||
durationSec: 10, label: "step\(i)")
|
||||
}
|
||||
var engine = IntervalEngine(steps: steps)
|
||||
engine.update(elapsed: 0, distance: 0)
|
||||
let events = engine.update(elapsed: 30, distance: 0)
|
||||
|
||||
XCTAssertEqual(engine.index, 3)
|
||||
XCTAssertEqual(events, [
|
||||
.stepFinished(index: 0), .stepStarted(index: 1),
|
||||
.stepFinished(index: 1), .stepStarted(index: 2),
|
||||
.stepFinished(index: 2), .stepStarted(index: 3),
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: Étapes en distance et étapes ouvertes
|
||||
|
||||
func testDistanceStepCompletesOnDistanceNotTime() {
|
||||
let steps = [
|
||||
CoachPlanStep(kind: .work, distanceM: 1000, label: "1 km"),
|
||||
CoachPlanStep(kind: .recovery, durationSec: 60, label: "récup"),
|
||||
]
|
||||
var engine = IntervalEngine(steps: steps)
|
||||
engine.update(elapsed: 0, distance: 0)
|
||||
|
||||
XCTAssertTrue(engine.update(elapsed: 600, distance: 999).transitions.isEmpty,
|
||||
"Dix minutes ne terminent pas une étape en distance")
|
||||
XCTAssertEqual(engine.progress().remainingDistanceM ?? -1, 1, accuracy: 0.001)
|
||||
|
||||
let events = engine.update(elapsed: 610, distance: 1000)
|
||||
XCTAssertEqual(events, [.stepFinished(index: 0), .stepStarted(index: 1)])
|
||||
}
|
||||
|
||||
func testOpenStepNeverEndsByItself() {
|
||||
let steps = [
|
||||
CoachPlanStep(kind: .warmup, label: "Pars quand tu es prêt"),
|
||||
CoachPlanStep(kind: .work, durationSec: 60, label: "effort"),
|
||||
]
|
||||
var engine = IntervalEngine(steps: steps)
|
||||
engine.update(elapsed: 0, distance: 0)
|
||||
XCTAssertTrue(engine.update(elapsed: 3600, distance: 8000).isEmpty,
|
||||
"Une étape ouverte ne se termine que sur ordre")
|
||||
XCTAssertNil(engine.progress().fraction)
|
||||
XCTAssertNil(engine.progress().remainingInStep)
|
||||
|
||||
let events = engine.advanceManually()
|
||||
XCTAssertEqual(events, [.stepFinished(index: 0), .stepStarted(index: 1)])
|
||||
// L'effort démarre maintenant, pas au début de la séance.
|
||||
XCTAssertEqual(engine.progress().remainingInStep ?? -1, 60, accuracy: 0.001)
|
||||
}
|
||||
|
||||
func testManualSkipStartsNextStepNow() {
|
||||
var engine = IntervalEngine(steps: cdcPlan())
|
||||
engine.update(elapsed: 0, distance: 0)
|
||||
engine.update(elapsed: 120, distance: 0) // au milieu de l'échauffement
|
||||
let events = engine.advanceManually()
|
||||
|
||||
XCTAssertEqual(events, [.stepFinished(index: 0), .stepStarted(index: 1)])
|
||||
XCTAssertEqual(engine.progress().elapsedInStep, 0, accuracy: 0.001,
|
||||
"Sauter une étape ne doit pas amputer la suivante")
|
||||
XCTAssertEqual(engine.progress().remainingInStep ?? -1, 60, accuracy: 0.001)
|
||||
}
|
||||
|
||||
func testManualAdvanceOnLastStepFinishesThePlan() {
|
||||
var engine = IntervalEngine(steps: [CoachPlanStep(kind: .work, label: "libre")])
|
||||
engine.update(elapsed: 0, distance: 0)
|
||||
XCTAssertEqual(engine.advanceManually(), [.stepFinished(index: 0), .planFinished])
|
||||
XCTAssertTrue(engine.isFinished)
|
||||
XCTAssertTrue(engine.advanceManually().isEmpty, "Plus rien après la fin")
|
||||
}
|
||||
|
||||
// MARK: Robustesse
|
||||
|
||||
func testHalfwayAnnouncedOncePerStep() {
|
||||
var engine = IntervalEngine(steps: cdcPlan())
|
||||
engine.update(elapsed: 0, distance: 0)
|
||||
XCTAssertTrue(engine.update(elapsed: 149, distance: 0).isEmpty)
|
||||
XCTAssertEqual(engine.update(elapsed: 150, distance: 0), [.halfway(index: 0)])
|
||||
XCTAssertTrue(engine.update(elapsed: 151, distance: 0).isEmpty,
|
||||
"La mi-parcours ne s'annonce qu'une fois")
|
||||
XCTAssertTrue(engine.update(elapsed: 200, distance: 0).isEmpty)
|
||||
}
|
||||
|
||||
func testGoingBackwardsInTimeIsClamped() {
|
||||
// Un appelant fautif ou une reprise après crash peut réinjecter un
|
||||
// temps antérieur. Mieux vaut borner que produire des durées négatives
|
||||
// qui remonteraient jusqu'à l'affichage.
|
||||
var engine = IntervalEngine(steps: cdcPlan())
|
||||
engine.update(elapsed: 0, distance: 0)
|
||||
engine.update(elapsed: 200, distance: 500)
|
||||
engine.update(elapsed: 10, distance: 5)
|
||||
|
||||
XCTAssertEqual(engine.progress().elapsedInStep, 200, accuracy: 0.001)
|
||||
XCTAssertEqual(engine.index, 0)
|
||||
}
|
||||
|
||||
func testEmptyPlanIsFinishedImmediately() {
|
||||
var engine = IntervalEngine(steps: [])
|
||||
XCTAssertTrue(engine.isFinished)
|
||||
XCTAssertTrue(engine.update(elapsed: 0, distance: 0).isEmpty)
|
||||
XCTAssertTrue(engine.progress().isFinished)
|
||||
}
|
||||
|
||||
func testReplayingTicksReproducesTheSameState() {
|
||||
// Propriété qui rend la reprise après crash triviale : le moteur est
|
||||
// une fonction de la suite des ticks, sans horloge interne.
|
||||
let ticks = stride(from: 0.0, through: 700.0, by: 3.0)
|
||||
var a = IntervalEngine(steps: cdcPlan())
|
||||
var b = IntervalEngine(steps: cdcPlan())
|
||||
for t in ticks { a.update(elapsed: t, distance: t * 2) }
|
||||
for t in ticks { b.update(elapsed: t, distance: t * 2) }
|
||||
XCTAssertEqual(a, b)
|
||||
XCTAssertEqual(a.progress(), b.progress())
|
||||
}
|
||||
|
||||
// MARK: Décodage du plan poussé par l'iPhone
|
||||
|
||||
func testDecodesPlanFromPhonePayload() throws {
|
||||
let json = """
|
||||
{
|
||||
"date": "2026-08-21",
|
||||
"sport": "running",
|
||||
"title": "CDC · Reprise séance 1",
|
||||
"steps": [
|
||||
{"kind": "warmup", "duration_sec": 300, "hr_zone": 1,
|
||||
"hr_bpm_min": 90, "hr_bpm_max": 105, "label": "5 min marche"},
|
||||
{"kind": "work", "duration_sec": 60, "hr_zone": 2,
|
||||
"hr_bpm_min": 110, "hr_bpm_max": 125, "label": "1' course, cadence 170+"}
|
||||
]
|
||||
}
|
||||
"""
|
||||
let plan = try JSONDecoder().decode(CoachSessionPlan.self, from: Data(json.utf8))
|
||||
XCTAssertEqual(plan.steps.count, 2)
|
||||
XCTAssertEqual(plan.steps[0].hrMaxBpm, 105)
|
||||
XCTAssertEqual(plan.steps[1].displayLabel, "1' course, cadence 170+")
|
||||
XCTAssertEqual(plan.plannedDurationSec, 360)
|
||||
XCTAssertEqual(plan.steps[1].goal, .time(60))
|
||||
}
|
||||
|
||||
func testPlannedDurationIsNilWhenAStepIsOpen() {
|
||||
let plan = CoachSessionPlan(date: "2026-08-21", sport: "running", steps: [
|
||||
CoachPlanStep(kind: .warmup, durationSec: 300),
|
||||
CoachPlanStep(kind: .work, label: "libre"),
|
||||
])
|
||||
XCTAssertNil(plan.plannedDurationSec,
|
||||
"Mieux vaut ne rien annoncer qu'annoncer un total faux")
|
||||
}
|
||||
|
||||
func testMissingLabelFallsBackToStepKind() {
|
||||
XCTAssertEqual(CoachPlanStep(kind: .recovery).displayLabel, "Récupération")
|
||||
XCTAssertEqual(CoachPlanStep(kind: .work, label: " ").displayLabel, "Effort")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user