Files
coach-ios/tests-linux/Tests/CoachModelTests/IntervalEngineTests.swift
Sylvain Bettinelli f2fa3cccb0 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>
2026-08-20 07:50:27 +00:00

268 lines
12 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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")
}
}