Compare commits
3 Commits
main
...
be1ffec5c1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be1ffec5c1 | ||
|
|
1a5b9cbcad | ||
|
|
f2fa3cccb0 |
35
COWORK.md
35
COWORK.md
@@ -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`.
|
||||||
|
|||||||
@@ -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 */,
|
||||||
|
|||||||
360
ios/App/CoachWatch/IntervalEngine.swift
Normal file
360
ios/App/CoachWatch/IntervalEngine.swift
Normal 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?
|
||||||
|
/// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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