Compare commits

..

3 Commits

Author SHA1 Message Date
Sylvain
be1ffec5c1 IntervalEngine rejoint la cible CoachWatch 2026-08-20 18:35:47 +02:00
Sylvain Bettinelli
1a5b9cbcad COWORK : le moteur d'intervalles, et une commande qui n'existait pas
Deux corrections au fichier qui sert de canal entre le dev Linux et le Mac.

1. Le chantier IntervalEngine (f2fa3cc, ce matin) n'y figurait pas — or c'est
   par ce fichier que le poste Mac apprend ce qu'il y a à faire. Ajouté avec
   l'étape bloquante : le fichier n'est PAS membre de la cible CoachWatch
   (grep rend 0), donc Xcode l'ignore et le projet compile très bien sans lui.
   Même piège que VmaTestView.swift.

   Noté aussi que les 42 tests Swift passent sur Linux (./tests-linux/run.sh),
   dont les 17 du moteur : la logique est validée, seule l'intégration reste.

2. La procédure de build disait `open ios/App/App.xcworkspace`. Ce fichier
   n'existe pas : le dépôt ne contient que App.xcodeproj, Capacitor 8 étant
   passé à SPM. La commande échouait telle quelle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 16:24:12 +00:00
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
5 changed files with 673 additions and 8 deletions

View File

@@ -76,9 +76,42 @@ ouvrait Safari**, où le cookie d'auth injecté dans la WKWebView n'existe pas :
`CoachLiveView` s'ouvre d'elle-même (le JS de `/live` appelle `CoachLiveView` s'ouvre d'elle-même (le JS de `/live` appelle
`openNativeLive`). C'est le point d'entrée qui manquait. `openNativeLive`). C'est le point d'entrée qui manquait.
## 🆕 Moteur d'intervalles (2026-08-20, dev — non câblé, non compilé)
Branche **`feat/watch-outdoor`** (poussée sur Gitea le 2026-08-20), commit
`f2fa3cc`. `IntervalEngine` guide les intervalles d'une séance au poignet.
**Pourquoi ce code existe** : 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. Il faut donc
écrire la machine à états nous-mêmes.
**Le moteur 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.
**42 tests Swift verts sur Linux** (`./tests-linux/run.sh`, toolchain
`~/workspace/toolchains/bin`), dont les 17 d'`IntervalEngine`. La logique est
donc validée — ce qui reste est l'intégration, que Linux ne peut pas compiler.
⚠️ **ÉTAPE MAC OBLIGATOIRE — le fichier n'est PAS dans le projet Xcode.**
Vérifié le 2026-08-20 : `grep -c "IntervalEngine" ios/App/App.xcodeproj/project.pbxproj`
rend **0**. Xcode l'ignore donc totalement, et **ça compile très bien sans lui**
même piège que `VmaTestView.swift`. À faire :
1. `cp ios/App/App.xcodeproj/project.pbxproj ios/App/App.xcodeproj/project.pbxproj.bak-interval`
2. Clic droit sur le dossier **CoachWatch***Add Files to "App"…*
`ios/App/CoachWatch/IntervalEngine.swift`
**décocher** « Copy items if needed », **cocher la cible `CoachWatch`**.
3. Contrôle : `grep -c "IntervalEngine.swift in Sources" ios/App/App.xcodeproj/project.pbxproj`
doit rendre **1**.
4. Commit + push du `pbxproj` immédiatement (règle 3 de ce fichier).
## 👉 À reprendre ## 👉 À reprendre
-**Phase 4 login — FAIT & DÉPLOYÉ** (révocation Apple à la suppression de compte) : code complet côté backend `coach_sportif` (commit `5ae6e2d`) + clé `.p8` déployée sur le VPS prod (vérifié 2026-06-26). Rien à coder. Détails : `coach_sportif/COWORK.md`. -**Phase 4 login — FAIT & DÉPLOYÉ** (révocation Apple à la suppression de compte) : code complet côté backend `coach_sportif` (commit `5ae6e2d`) + clé `.p8` déployée sur le VPS prod (vérifié 2026-06-26). Rien à coder. Détails : `coach_sportif/COWORK.md`.
- **iOS — bloqué Mac** : builder + uploader TestFlight le natif accumulé (login Apple+Google natif, watchOS live, HeartRateRangeAlert). Sur le Mac : `cd ~/coach-ios && git pull && npm install && npx cap sync ios && open ios/App/App.xcworkspace` → Clean Build Folder → Archive → Upload. - **iOS — bloqué Mac** : builder + uploader TestFlight le natif accumulé (login Apple+Google natif, watchOS live, HeartRateRangeAlert). Sur le Mac : `cd ~/coach-ios && git pull && npm install && npx cap sync ios && open ios/App/App.xcodeproj` → Clean Build Folder → Archive → Upload.
- ⚠️ **Corrigé le 2026-08-20** : cette ligne disait `open ios/App/App.xcworkspace`. **Ce fichier n'existe pas** (vérifié : le dépôt ne contient que `App.xcodeproj`). Depuis **Capacitor 8, les dépendances passent par SPM** et il n'y a plus de workspace CocoaPods. La commande échouait donc telle quelle.
- ⚠️ **Fixes robustesse 2026-07-01 (dev, non compilés)** à valider au prochain build : `CoachWidgetBridge` (score lu en `NSNumber.intValue` → corrige un score float perdu ; `save` renvoie Bool → `call.reject` si App Group KO), `CoachAppleAuth` (double-tap : rejette l'ancien `pendingCall` ; erreur testée par domaine `ASAuthorizationError`). Swift pur, aucun nouveau fichier ni capability. - ⚠️ **Fixes robustesse 2026-07-01 (dev, non compilés)** à valider au prochain build : `CoachWidgetBridge` (score lu en `NSNumber.intValue` → corrige un score float perdu ; `save` renvoie Bool → `call.reject` si App Group KO), `CoachAppleAuth` (double-tap : rejette l'ancien `pendingCall` ; erreur testée par domaine `ASAuthorizationError`). Swift pur, aucun nouveau fichier ni capability.
- ⚠️ **Checklist pré-App-Store (audit sécu 2026-07-01)** : `ios/App/App/App.entitlements` a `aps-environment = development`. Pour la soumission **App Store/TestFlight**, l'APNs prod exige `production`. NON changé côté dev (casserait le push en dev device — arbitrage de signing à faire sur Mac : soit basculer `production` avant l'archive de distribution, soit laisser Xcode le gérer via le profil de distribution auto). À trancher/tester au moment du build release. - ⚠️ **Checklist pré-App-Store (audit sécu 2026-07-01)** : `ios/App/App/App.entitlements` a `aps-environment = development`. Pour la soumission **App Store/TestFlight**, l'APNs prod exige `production`. NON changé côté dev (casserait le push en dev device — arbitrage de signing à faire sur Mac : soit basculer `production` avant l'archive de distribution, soit laisser Xcode le gérer via le profil de distribution auto). À trancher/tester au moment du build release.
- **watchOS — bloqué Mac** : test device + TestFlight de la cible `CoachWatch` (Phases 1-3 réalisées, build vert sim). Cf. `HANDOFF-WATCHOS.md`. - **watchOS — bloqué Mac** : test device + TestFlight de la cible `CoachWatch` (Phases 1-3 réalisées, build vert sim). Cf. `HANDOFF-WATCHOS.md`.

View File

@@ -10,7 +10,6 @@
01468471ECA28EA66AFE2D6C /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0EB6C76F68679844EC72178F /* Foundation.framework */; }; 01468471ECA28EA66AFE2D6C /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0EB6C76F68679844EC72178F /* Foundation.framework */; };
0318E7E610501FC1F958251B /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3342ACCE085010828CF63565 /* Assets.xcassets */; }; 0318E7E610501FC1F958251B /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3342ACCE085010828CF63565 /* Assets.xcassets */; };
19F735674999FD63AB1C566F /* CoachWidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96752F30FFA0FAA9E42F9BBA /* CoachWidgetSnapshot.swift */; }; 19F735674999FD63AB1C566F /* CoachWidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96752F30FFA0FAA9E42F9BBA /* CoachWidgetSnapshot.swift */; };
C0AC4E1Z0002HRZONES00001 /* HeartRateZones.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0001HRZONES00001 /* HeartRateZones.swift */; };
1B690F0D16AB76A6854CA1F7 /* CoachLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 431C7B10248D8EED4D397AB0 /* CoachLiveActivityAttributes.swift */; }; 1B690F0D16AB76A6854CA1F7 /* CoachLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 431C7B10248D8EED4D397AB0 /* CoachLiveActivityAttributes.swift */; };
21B242F8E61FB07C476C477C /* CoachLiveBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9DF3346C49A690C3C3DF2F3 /* CoachLiveBridge.swift */; }; 21B242F8E61FB07C476C477C /* CoachLiveBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9DF3346C49A690C3C3DF2F3 /* CoachLiveBridge.swift */; };
23F47FD81F646516727B5317 /* RoutineStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 969DB880395BDEB107C9F2FE /* RoutineStore.swift */; }; 23F47FD81F646516727B5317 /* RoutineStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 969DB880395BDEB107C9F2FE /* RoutineStore.swift */; };
@@ -31,14 +30,13 @@
548F8BFA06417B35474BD69E /* CoachLiveView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 340B99D5EA4DB985D7DFDA10 /* CoachLiveView.swift */; }; 548F8BFA06417B35474BD69E /* CoachLiveView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 340B99D5EA4DB985D7DFDA10 /* CoachLiveView.swift */; };
60061E60061E60061E060001 /* CoachGoogleAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60061E60061E60061E060002 /* CoachGoogleAuth.swift */; }; 60061E60061E60061E060001 /* CoachGoogleAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60061E60061E60061E060002 /* CoachGoogleAuth.swift */; };
6442A95576AFC77377D0DC61 /* RoutineGuidedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2D78A1BF31D30C5AEC24E7C /* RoutineGuidedView.swift */; }; 6442A95576AFC77377D0DC61 /* RoutineGuidedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2D78A1BF31D30C5AEC24E7C /* RoutineGuidedView.swift */; };
A1B2C3D4E5F60718293A4B5D /* VmaTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B5C /* VmaTestView.swift */; };
694B2F69591CAA85CE5DEC3A /* CoachLiveActivityWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBBF61A35F291A021BA75C60 /* CoachLiveActivityWidget.swift */; }; 694B2F69591CAA85CE5DEC3A /* CoachLiveActivityWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBBF61A35F291A021BA75C60 /* CoachLiveActivityWidget.swift */; };
79A94FD334CC9EC24D1FB700 /* CoachWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0276928E86E785EEA434057C /* CoachWidgets.swift */; }; 79A94FD334CC9EC24D1FB700 /* CoachWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0276928E86E785EEA434057C /* CoachWidgets.swift */; };
880A76D1302B601000F582D5 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 880A76D0302B601000F582D5 /* WidgetKit.framework */; }; 880A76D1302B601000F582D5 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 880A76D0302B601000F582D5 /* WidgetKit.framework */; };
880A76D3302B601000F582D5 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 880A76D2302B601000F582D5 /* SwiftUI.framework */; }; 880A76D3302B601000F582D5 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 880A76D2302B601000F582D5 /* SwiftUI.framework */; };
880A76DE302B601100F582D5 /* CoachWatchWidgetsExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 880A76CF302B601000F582D5 /* CoachWatchWidgetsExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 880A76DE302B601100F582D5 /* CoachWatchWidgetsExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 880A76CF302B601000F582D5 /* CoachWatchWidgetsExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
880A76E6302B62FB00F582D5 /* CoachWidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96752F30FFA0FAA9E42F9BBA /* CoachWidgetSnapshot.swift */; }; 880A76E6302B62FB00F582D5 /* CoachWidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96752F30FFA0FAA9E42F9BBA /* CoachWidgetSnapshot.swift */; };
C0AC4E1Z0003HRZONES00001 /* HeartRateZones.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0001HRZONES00001 /* HeartRateZones.swift */; }; 88342F7D30376311002D1811 /* IntervalEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88342F7C30376311002D1811 /* IntervalEngine.swift */; };
88924CCE2FD2A733002F3A80 /* GoogleSignIn in Frameworks */ = {isa = PBXBuildFile; productRef = 88924CCD2FD2A733002F3A80 /* GoogleSignIn */; }; 88924CCE2FD2A733002F3A80 /* GoogleSignIn in Frameworks */ = {isa = PBXBuildFile; productRef = 88924CCD2FD2A733002F3A80 /* GoogleSignIn */; };
88ABE0043024C45A002636FD /* CoachQuickLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88ABE0023024C45A002636FD /* CoachQuickLog.swift */; }; 88ABE0043024C45A002636FD /* CoachQuickLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88ABE0023024C45A002636FD /* CoachQuickLog.swift */; };
88ABE0053024C45A002636FD /* CoachQuickSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88ABE0033024C45A002636FD /* CoachQuickSync.swift */; }; 88ABE0053024C45A002636FD /* CoachQuickSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88ABE0033024C45A002636FD /* CoachQuickSync.swift */; };
@@ -50,14 +48,17 @@
95D0478A499056FE922211EA /* RoutineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA5C5902EFB7F60563678E46 /* RoutineView.swift */; }; 95D0478A499056FE922211EA /* RoutineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA5C5902EFB7F60563678E46 /* RoutineView.swift */; };
97116C31D4CC40F71A6A0FE7 /* LiveStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6030BD533EBC2D1D26DB9479 /* LiveStore.swift */; }; 97116C31D4CC40F71A6A0FE7 /* LiveStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6030BD533EBC2D1D26DB9479 /* LiveStore.swift */; };
97600B69ADF2F19B56E79621 /* CoachWidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96752F30FFA0FAA9E42F9BBA /* CoachWidgetSnapshot.swift */; }; 97600B69ADF2F19B56E79621 /* CoachWidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96752F30FFA0FAA9E42F9BBA /* CoachWidgetSnapshot.swift */; };
C0AC4E1Z0004HRZONES00001 /* HeartRateZones.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0001HRZONES00001 /* HeartRateZones.swift */; };
9BED4C04C38FCB8634CE2A94 /* CoachLiveActivity.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 9B5664FCDB4DDE62514D3A1E /* CoachLiveActivity.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 9BED4C04C38FCB8634CE2A94 /* CoachLiveActivity.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 9B5664FCDB4DDE62514D3A1E /* CoachLiveActivity.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
9E88D51C0B0255D24274DACC /* CoachLiveActivityBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BA75EEC3CBF1F46A9028C66 /* CoachLiveActivityBundle.swift */; }; 9E88D51C0B0255D24274DACC /* CoachLiveActivityBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BA75EEC3CBF1F46A9028C66 /* CoachLiveActivityBundle.swift */; };
A1B2C3D4E5F60718293A4B5D /* VmaTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B5C /* VmaTestView.swift */; };
A99E51A99E51A99E51A90001 /* CoachAppleAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = A99E51A99E51A99E51A90002 /* CoachAppleAuth.swift */; }; A99E51A99E51A99E51A90001 /* CoachAppleAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = A99E51A99E51A99E51A90002 /* CoachAppleAuth.swift */; };
B06AB24B350B221F38D99E17 /* ConnectivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A420239FC6D065C8A599B61C /* ConnectivityManager.swift */; }; B06AB24B350B221F38D99E17 /* ConnectivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A420239FC6D065C8A599B61C /* ConnectivityManager.swift */; };
B130613187FE0661DDF636C9 /* CoachWidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96752F30FFA0FAA9E42F9BBA /* CoachWidgetSnapshot.swift */; }; B130613187FE0661DDF636C9 /* CoachWidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96752F30FFA0FAA9E42F9BBA /* CoachWidgetSnapshot.swift */; };
C0AC4E1Z0005HRZONES00001 /* HeartRateZones.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0001HRZONES00001 /* HeartRateZones.swift */; };
C0AC4A07811751FB78AA0001 /* CoachAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4A07811751FB78AA0002 /* CoachAuth.swift */; }; C0AC4A07811751FB78AA0001 /* CoachAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4A07811751FB78AA0002 /* CoachAuth.swift */; };
C0AC4E1Z0002HRZONES00001 /* HeartRateZones.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0001HRZONES00001 /* HeartRateZones.swift */; };
C0AC4E1Z0003HRZONES00001 /* HeartRateZones.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0001HRZONES00001 /* HeartRateZones.swift */; };
C0AC4E1Z0004HRZONES00001 /* HeartRateZones.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0001HRZONES00001 /* HeartRateZones.swift */; };
C0AC4E1Z0005HRZONES00001 /* HeartRateZones.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0001HRZONES00001 /* HeartRateZones.swift */; };
D1384CFA4960060CDBF05FC3 /* CoachLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 431C7B10248D8EED4D397AB0 /* CoachLiveActivityAttributes.swift */; }; D1384CFA4960060CDBF05FC3 /* CoachLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 431C7B10248D8EED4D397AB0 /* CoachLiveActivityAttributes.swift */; };
D1BD5B1990CF3F2B9C5D0001 /* CoachWorkoutKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1BD5B1990CF3F2B9C5D0002 /* CoachWorkoutKit.swift */; }; D1BD5B1990CF3F2B9C5D0001 /* CoachWorkoutKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1BD5B1990CF3F2B9C5D0002 /* CoachWorkoutKit.swift */; };
E1E8BD7CF9C19E56CE0F57C4 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D61D7B2F5AFEF834423E07AB /* Foundation.framework */; }; E1E8BD7CF9C19E56CE0F57C4 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D61D7B2F5AFEF834423E07AB /* Foundation.framework */; };
@@ -152,6 +153,7 @@
880A76D0302B601000F582D5 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; 880A76D0302B601000F582D5 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
880A76D2302B601000F582D5 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; 880A76D2302B601000F582D5 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
880A76E7302B634400F582D5 /* CoachWatchWidgetsExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = CoachWatchWidgetsExtension.entitlements; sourceTree = "<group>"; }; 880A76E7302B634400F582D5 /* CoachWatchWidgetsExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = CoachWatchWidgetsExtension.entitlements; sourceTree = "<group>"; };
88342F7C30376311002D1811 /* IntervalEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntervalEngine.swift; sourceTree = "<group>"; };
884A1F9D3020E3C100457184 /* CoachLiveActivity.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = CoachLiveActivity.entitlements; sourceTree = "<group>"; }; 884A1F9D3020E3C100457184 /* CoachLiveActivity.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = CoachLiveActivity.entitlements; sourceTree = "<group>"; };
88A837392FAA741C00DDD144 /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = "<group>"; }; 88A837392FAA741C00DDD144 /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = "<group>"; };
88ABE0023024C45A002636FD /* CoachQuickLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachQuickLog.swift; sourceTree = "<group>"; }; 88ABE0023024C45A002636FD /* CoachQuickLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachQuickLog.swift; sourceTree = "<group>"; };
@@ -163,18 +165,18 @@
8AC9C9B9971F55027D227A06 /* WorkoutManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WorkoutManager.swift; sourceTree = "<group>"; }; 8AC9C9B9971F55027D227A06 /* WorkoutManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WorkoutManager.swift; sourceTree = "<group>"; };
958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; }; 958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; };
96752F30FFA0FAA9E42F9BBA /* CoachWidgetSnapshot.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachWidgetSnapshot.swift; sourceTree = "<group>"; }; 96752F30FFA0FAA9E42F9BBA /* CoachWidgetSnapshot.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachWidgetSnapshot.swift; sourceTree = "<group>"; };
C0AC4E1Z0001HRZONES00001 /* HeartRateZones.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HeartRateZones.swift; sourceTree = "<group>"; };
969DB880395BDEB107C9F2FE /* RoutineStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RoutineStore.swift; sourceTree = "<group>"; }; 969DB880395BDEB107C9F2FE /* RoutineStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RoutineStore.swift; sourceTree = "<group>"; };
97BC6E07E6984342AE95C312 /* CoachRoutineBridge.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachRoutineBridge.swift; sourceTree = "<group>"; }; 97BC6E07E6984342AE95C312 /* CoachRoutineBridge.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachRoutineBridge.swift; sourceTree = "<group>"; };
9B5664FCDB4DDE62514D3A1E /* CoachLiveActivity.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = CoachLiveActivity.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 9B5664FCDB4DDE62514D3A1E /* CoachLiveActivity.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = CoachLiveActivity.appex; sourceTree = BUILT_PRODUCTS_DIR; };
A1B2C3D4E5F60718293A4B5C /* VmaTestView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VmaTestView.swift; sourceTree = "<group>"; };
A420239FC6D065C8A599B61C /* ConnectivityManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConnectivityManager.swift; sourceTree = "<group>"; }; A420239FC6D065C8A599B61C /* ConnectivityManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConnectivityManager.swift; sourceTree = "<group>"; };
A99E51A99E51A99E51A90002 /* CoachAppleAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachAppleAuth.swift; sourceTree = "<group>"; }; A99E51A99E51A99E51A90002 /* CoachAppleAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachAppleAuth.swift; sourceTree = "<group>"; };
A9DF3346C49A690C3C3DF2F3 /* CoachLiveBridge.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachLiveBridge.swift; sourceTree = "<group>"; }; A9DF3346C49A690C3C3DF2F3 /* CoachLiveBridge.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachLiveBridge.swift; sourceTree = "<group>"; };
AA5C5902EFB7F60563678E46 /* RoutineView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RoutineView.swift; sourceTree = "<group>"; }; AA5C5902EFB7F60563678E46 /* RoutineView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RoutineView.swift; sourceTree = "<group>"; };
B93DA6B121E94B11DF938C90 /* LiveActivityManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LiveActivityManager.swift; sourceTree = "<group>"; }; B93DA6B121E94B11DF938C90 /* LiveActivityManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LiveActivityManager.swift; sourceTree = "<group>"; };
C0AC4A07811751FB78AA0002 /* CoachAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachAuth.swift; sourceTree = "<group>"; }; C0AC4A07811751FB78AA0002 /* CoachAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachAuth.swift; sourceTree = "<group>"; };
C0AC4E1Z0001HRZONES00001 /* HeartRateZones.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HeartRateZones.swift; sourceTree = "<group>"; };
C2D78A1BF31D30C5AEC24E7C /* RoutineGuidedView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RoutineGuidedView.swift; sourceTree = "<group>"; }; C2D78A1BF31D30C5AEC24E7C /* RoutineGuidedView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RoutineGuidedView.swift; sourceTree = "<group>"; };
A1B2C3D4E5F60718293A4B5C /* VmaTestView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VmaTestView.swift; sourceTree = "<group>"; };
D09B589FA572B2C14E8CD835 /* CoachWatch.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = CoachWatch.entitlements; sourceTree = "<group>"; }; D09B589FA572B2C14E8CD835 /* CoachWatch.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = CoachWatch.entitlements; sourceTree = "<group>"; };
D1BD5B1990CF3F2B9C5D0002 /* CoachWorkoutKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachWorkoutKit.swift; sourceTree = "<group>"; }; D1BD5B1990CF3F2B9C5D0002 /* CoachWorkoutKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachWorkoutKit.swift; sourceTree = "<group>"; };
D61D7B2F5AFEF834423E07AB /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; D61D7B2F5AFEF834423E07AB /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; };
@@ -323,6 +325,7 @@
5A8EA0CF92316E437E9053A0 /* CoachWatch */ = { 5A8EA0CF92316E437E9053A0 /* CoachWatch */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
88342F7C30376311002D1811 /* IntervalEngine.swift */,
F6BEAEF6F6274D61CAF22636 /* CoachWatchApp.swift */, F6BEAEF6F6274D61CAF22636 /* CoachWatchApp.swift */,
8A06CDFF5B4F7852B6329862 /* ContentView.swift */, 8A06CDFF5B4F7852B6329862 /* ContentView.swift */,
82122ACF7E5A608AD1D55D24 /* Info.plist */, 82122ACF7E5A608AD1D55D24 /* Info.plist */,
@@ -591,6 +594,7 @@
B06AB24B350B221F38D99E17 /* ConnectivityManager.swift in Sources */, B06AB24B350B221F38D99E17 /* ConnectivityManager.swift in Sources */,
95D0478A499056FE922211EA /* RoutineView.swift in Sources */, 95D0478A499056FE922211EA /* RoutineView.swift in Sources */,
6442A95576AFC77377D0DC61 /* RoutineGuidedView.swift in Sources */, 6442A95576AFC77377D0DC61 /* RoutineGuidedView.swift in Sources */,
88342F7D30376311002D1811 /* IntervalEngine.swift in Sources */,
A1B2C3D4E5F60718293A4B5D /* VmaTestView.swift in Sources */, A1B2C3D4E5F60718293A4B5D /* VmaTestView.swift in Sources */,
23F47FD81F646516727B5317 /* RoutineStore.swift in Sources */, 23F47FD81F646516727B5317 /* RoutineStore.swift in Sources */,
436442D6BF54833C59D9E668 /* WorkoutManager.swift in Sources */, 436442D6BF54833C59D9E668 /* WorkoutManager.swift in Sources */,

View File

@@ -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?
/// 01, `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
}
}
}

View File

@@ -0,0 +1 @@
../../../ios/App/CoachWatch/IntervalEngine.swift

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