Compare commits
6 Commits
main
...
19fbfd116e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19fbfd116e | ||
|
|
eb523ff205 | ||
|
|
acbb2c6896 | ||
|
|
be1ffec5c1 | ||
|
|
1a5b9cbcad | ||
|
|
f2fa3cccb0 |
56
COWORK.md
56
COWORK.md
@@ -76,9 +76,63 @@ 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
|
||||
`openNativeLive`). C'est le point d'entrée qui manquait.
|
||||
|
||||
## 🆕 Chantier montre outdoor (2026-08-20, dev — non compilé)
|
||||
|
||||
Branche **`feat/watch-outdoor`**, commits `f2fa3cc` (moteur d'intervalles) et
|
||||
`a04dfc4` (trace GPS). `IntervalEngine` guide les intervalles d'une séance au
|
||||
poignet ; `RouteFilter` + `LocationTracker` enregistrent le parcours.
|
||||
|
||||
**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.
|
||||
|
||||
✅ **57 tests Swift verts sur Linux** (`./tests-linux/run.sh`, toolchain
|
||||
`~/workspace/toolchains/bin`) : 17 pour `IntervalEngine`, 15 pour `RouteFilter`.
|
||||
Les deux ont été **vérifiés rouges** en neutralisant leur correction (5 échecs
|
||||
sans les frontières théoriques du moteur, 4 sans le lissage du dénivelé). La
|
||||
logique est donc validée — reste l'intégration, que Linux ne peut pas compiler.
|
||||
|
||||
✅ **Plus d'« Add Files » à faire** — correction du 2026-08-20 après-midi. Une
|
||||
note antérieure de ce fichier annonçait `IntervalEngine` absent du projet : ce
|
||||
n'est plus vrai. Les **trois** fichiers sont déclarés dans la cible `CoachWatch`
|
||||
directement dans le `pbxproj` (sauvegarde `project.pbxproj.bak-outdoor`).
|
||||
|
||||
Contrôle avant de builder — chacun doit rendre **2** (déclaration + phase de
|
||||
compilation, soit 1 cible) :
|
||||
```bash
|
||||
for f in IntervalEngine RouteFilter LocationTracker; do
|
||||
echo "$f: $(grep -c "$f.swift in Sources" ios/App/App.xcodeproj/project.pbxproj)"
|
||||
done
|
||||
```
|
||||
|
||||
⚠️ **À vérifier au premier build, dans cet ordre** :
|
||||
1. **Une séance extérieure demande la localisation** (feuille « lorsque l'app
|
||||
est active »). Si rien n'apparaît, la trace ne partira pas.
|
||||
2. **La sortie a bien un parcours dans Santé** à la fin. C'est le test qui
|
||||
compte : `finishRoute` doit être appelé APRÈS `finishWorkout`, sinon la
|
||||
trace existe sans être associée à la séance.
|
||||
3. **La FC remonte toujours sur `/live`** — `WorkoutManager` a été modifié,
|
||||
c'est le test de non-régression prioritaire.
|
||||
4. Écran éteint, poignet baissé : les positions continuent d'arriver. Si elles
|
||||
s'arrêtent, chercher du côté du CPU (une boucle d'affichage trop rapide fait
|
||||
suspendre l'app), pas du côté des autorisations.
|
||||
|
||||
⚠️ `Info.plist` de `CoachWatch` gagne `UIBackgroundModes = [location]` et
|
||||
`NSLocationWhenInUseUsageDescription`. **La clé de background est vitale** :
|
||||
armer `allowsBackgroundLocationUpdates` sans elle termine l'app. Un garde-fou
|
||||
la vérifie au démarrage, mais si elle disparaissait du build, le suivi
|
||||
retomberait silencieusement au premier plan seul.
|
||||
|
||||
## 👉 À 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`.
|
||||
- **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.
|
||||
- ⚠️ **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`.
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
01468471ECA28EA66AFE2D6C /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0EB6C76F68679844EC72178F /* Foundation.framework */; };
|
||||
0318E7E610501FC1F958251B /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3342ACCE085010828CF63565 /* Assets.xcassets */; };
|
||||
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 */; };
|
||||
21B242F8E61FB07C476C477C /* CoachLiveBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9DF3346C49A690C3C3DF2F3 /* CoachLiveBridge.swift */; };
|
||||
23F47FD81F646516727B5317 /* RoutineStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 969DB880395BDEB107C9F2FE /* RoutineStore.swift */; };
|
||||
@@ -19,6 +18,8 @@
|
||||
2F86A358498667B66E5EFC60 /* CoachRoutineBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97BC6E07E6984342AE95C312 /* CoachRoutineBridge.swift */; };
|
||||
2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; };
|
||||
3E6740C521EF14892687BCF7 /* CoachWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 2517374227A4072A4D6AFC15 /* CoachWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
C0AC4E1Z0011OUTDOOR0002 /* RouteFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0010OUTDOOR0002 /* RouteFilter.swift */; };
|
||||
C0AC4E1Z0011OUTDOOR0003 /* LocationTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0010OUTDOOR0003 /* LocationTracker.swift */; };
|
||||
436442D6BF54833C59D9E668 /* WorkoutManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AC9C9B9971F55027D227A06 /* WorkoutManager.swift */; };
|
||||
4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */ = {isa = PBXBuildFile; productRef = 4D22ABE82AF431CB00220026 /* CapApp-SPM */; };
|
||||
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
|
||||
@@ -31,14 +32,13 @@
|
||||
548F8BFA06417B35474BD69E /* CoachLiveView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 340B99D5EA4DB985D7DFDA10 /* CoachLiveView.swift */; };
|
||||
60061E60061E60061E060001 /* CoachGoogleAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60061E60061E60061E060002 /* CoachGoogleAuth.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 */; };
|
||||
79A94FD334CC9EC24D1FB700 /* CoachWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0276928E86E785EEA434057C /* CoachWidgets.swift */; };
|
||||
880A76D1302B601000F582D5 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 880A76D0302B601000F582D5 /* WidgetKit.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, ); }; };
|
||||
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 */; };
|
||||
88ABE0043024C45A002636FD /* CoachQuickLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88ABE0023024C45A002636FD /* CoachQuickLog.swift */; };
|
||||
88ABE0053024C45A002636FD /* CoachQuickSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88ABE0033024C45A002636FD /* CoachQuickSync.swift */; };
|
||||
@@ -50,14 +50,17 @@
|
||||
95D0478A499056FE922211EA /* RoutineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA5C5902EFB7F60563678E46 /* RoutineView.swift */; };
|
||||
97116C31D4CC40F71A6A0FE7 /* LiveStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6030BD533EBC2D1D26DB9479 /* LiveStore.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, ); }; };
|
||||
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 */; };
|
||||
B06AB24B350B221F38D99E17 /* ConnectivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A420239FC6D065C8A599B61C /* ConnectivityManager.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 */; };
|
||||
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 */; };
|
||||
D1BD5B1990CF3F2B9C5D0001 /* CoachWorkoutKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1BD5B1990CF3F2B9C5D0002 /* CoachWorkoutKit.swift */; };
|
||||
E1E8BD7CF9C19E56CE0F57C4 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D61D7B2F5AFEF834423E07AB /* Foundation.framework */; };
|
||||
@@ -152,6 +155,7 @@
|
||||
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; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -160,21 +164,23 @@
|
||||
88D1DD5D302B46B000B9BA42 /* CoachAuth.swift.example */ = {isa = PBXFileReference; lastKnownFileType = text; path = CoachAuth.swift.example; sourceTree = "<group>"; };
|
||||
88F28EB02FBB330E00E8306E /* CoachWorkoutObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachWorkoutObserver.swift; sourceTree = "<group>"; };
|
||||
8A06CDFF5B4F7852B6329862 /* ContentView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||
C0AC4E1Z0010OUTDOOR0002 /* RouteFilter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RouteFilter.swift; sourceTree = "<group>"; };
|
||||
C0AC4E1Z0010OUTDOOR0003 /* LocationTracker.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LocationTracker.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; };
|
||||
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>"; };
|
||||
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; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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; };
|
||||
@@ -323,6 +329,7 @@
|
||||
5A8EA0CF92316E437E9053A0 /* CoachWatch */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
88342F7C30376311002D1811 /* IntervalEngine.swift */,
|
||||
F6BEAEF6F6274D61CAF22636 /* CoachWatchApp.swift */,
|
||||
8A06CDFF5B4F7852B6329862 /* ContentView.swift */,
|
||||
82122ACF7E5A608AD1D55D24 /* Info.plist */,
|
||||
@@ -333,6 +340,8 @@
|
||||
A1B2C3D4E5F60718293A4B5C /* VmaTestView.swift */,
|
||||
969DB880395BDEB107C9F2FE /* RoutineStore.swift */,
|
||||
8AC9C9B9971F55027D227A06 /* WorkoutManager.swift */,
|
||||
C0AC4E1Z0010OUTDOOR0003 /* LocationTracker.swift */,
|
||||
C0AC4E1Z0010OUTDOOR0002 /* RouteFilter.swift */,
|
||||
3342ACCE085010828CF63565 /* Assets.xcassets */,
|
||||
);
|
||||
path = CoachWatch;
|
||||
@@ -591,9 +600,12 @@
|
||||
B06AB24B350B221F38D99E17 /* ConnectivityManager.swift in Sources */,
|
||||
95D0478A499056FE922211EA /* RoutineView.swift in Sources */,
|
||||
6442A95576AFC77377D0DC61 /* RoutineGuidedView.swift in Sources */,
|
||||
88342F7D30376311002D1811 /* IntervalEngine.swift in Sources */,
|
||||
A1B2C3D4E5F60718293A4B5D /* VmaTestView.swift in Sources */,
|
||||
23F47FD81F646516727B5317 /* RoutineStore.swift in Sources */,
|
||||
436442D6BF54833C59D9E668 /* WorkoutManager.swift in Sources */,
|
||||
C0AC4E1Z0011OUTDOOR0003 /* LocationTracker.swift in Sources */,
|
||||
C0AC4E1Z0011OUTDOOR0002 /* RouteFilter.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
||||
1093
ios/App/App.xcodeproj/project.pbxproj.bak-outdoor
Normal file
1093
ios/App/App.xcodeproj/project.pbxproj.bak-outdoor
Normal file
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,18 @@
|
||||
<string>Coach Hypnotruck lit ta fréquence cardiaque et tes calories pendant la séance pour les afficher en direct sur ton iPhone.</string>
|
||||
<key>NSHealthUpdateUsageDescription</key>
|
||||
<string>Coach Hypnotruck enregistre ta séance dans l'app Santé.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>Coach Hypnotruck suit ta position pendant la séance pour tracer ton parcours, afficher la carte et mesurer le dénivelé.</string>
|
||||
<!-- ⚠️ `location` est requis par `allowsBackgroundLocationUpdates` : armer ce
|
||||
drapeau sans la clé TERMINE l'app (« is a fatal error that terminates
|
||||
the app », doc Apple). `LocationTracker.backgroundModeDeclared` vérifie
|
||||
donc cette clé avant d'armer quoi que ce soit. La clé qui porte
|
||||
`location` sur watchOS est `UIBackgroundModes`, distincte de
|
||||
`WKBackgroundModes` qui porte `workout-processing`. -->
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>location</string>
|
||||
</array>
|
||||
<key>WKApplication</key>
|
||||
<true/>
|
||||
<key>WKBackgroundModes</key>
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
318
ios/App/CoachWatch/LocationTracker.swift
Normal file
318
ios/App/CoachWatch/LocationTracker.swift
Normal file
@@ -0,0 +1,318 @@
|
||||
import Foundation
|
||||
import CoreLocation
|
||||
import HealthKit
|
||||
import os
|
||||
|
||||
private let locationLog = Logger(subsystem: "ch.hypnotruck.coach.watchkitapp", category: "location")
|
||||
|
||||
/* GPS de la séance : flux de positions, trace HealthKit, distance et D+.
|
||||
*
|
||||
* Le filtrage vit dans `RouteFilter` (Foundation pur, testé sur Linux). Ce
|
||||
* fichier ne fait que le brancher sur CoreLocation et HealthKit — c'est la
|
||||
* seule partie qui exige un poignet pour être validée.
|
||||
*
|
||||
* **Pourquoi enregistrer la trace nous-mêmes.** Jusqu'ici les traces venaient
|
||||
* de l'app *Exercice* d'Apple, relues après coup côté iPhone par
|
||||
* `CoachHealthRoute`. Dès qu'une séance est lancée depuis CoachWatch, plus
|
||||
* personne n'écrit de `HKWorkoutRoute` : sans ce fichier, la sortie n'aurait
|
||||
* aucune trace, ni sur la carte ni dans Santé.
|
||||
*
|
||||
* ⚠️ **Quatre pièges, tous documentés et tous coûteux.**
|
||||
*
|
||||
* 1. `allowsBackgroundLocationUpdates = true` **sans** `UIBackgroundModes` =
|
||||
* `location` dans l'Info.plist **termine l'app** — Apple : « is a fatal
|
||||
* error that terminates the app ». D'où le garde-fou `backgroundModeDeclared`
|
||||
* ci-dessous, qui vérifie le plist avant d'armer le drapeau.
|
||||
* 2. **Ne jamais demander « Always » sur watchOS.** Un ingénieur DTS d'Apple
|
||||
* décrit ce prompt comme « mostly a placeholder » et le parcours comme
|
||||
* « undefined behavior » : le statut boucle jusqu'à revenir à
|
||||
* `.notDetermined`. `requestWhenInUseAuthorization()` suffit pour continuer
|
||||
* à recevoir des positions en arrière-plan.
|
||||
* 3. **Démarrer au premier plan, et ne plus jamais arrêter.** watchOS interdit
|
||||
* de relancer les mises à jour depuis l'arrière-plan : une pause qui
|
||||
* couperait le GPS ne pourrait pas le rallumer avant le retour à l'écran.
|
||||
* On garde donc le flux ouvert et on ignore les points pendant la pause.
|
||||
* 4. **Le CPU tue le GPS.** watchOS suspend une app qui consomme trop en
|
||||
* arrière-plan, et les positions s'arrêtent sans la moindre erreur — un
|
||||
* développeur a perdu les siennes à cause d'un rafraîchissement d'écran à
|
||||
* 1/100 s. D'où l'insertion des points par lots plutôt qu'un par un.
|
||||
*/
|
||||
@MainActor
|
||||
final class LocationTracker: NSObject, ObservableObject {
|
||||
static let shared = LocationTracker()
|
||||
|
||||
/// Trace décimée pour l'affichage carte. Bornée : une sortie de 3 h à 1 Hz
|
||||
/// produirait plus de 10 000 points, que la montre ne peut pas redessiner
|
||||
/// à chaque rafraîchissement sans se faire suspendre (piège 4).
|
||||
@Published private(set) var trackForMap: [GeoFix] = []
|
||||
@Published private(set) var lastFix: GeoFix?
|
||||
@Published private(set) var distanceMeters: Double = 0
|
||||
@Published private(set) var ascentMeters: Double = 0
|
||||
@Published private(set) var descentMeters: Double = 0
|
||||
/// Rayon d'incertitude du dernier point reçu, accepté ou non — c'est la
|
||||
/// jauge « qualité GPS » de l'écran.
|
||||
@Published private(set) var horizontalAccuracy: Double?
|
||||
@Published private(set) var authorizationStatus: CLAuthorizationStatus = .notDetermined
|
||||
@Published private(set) var isTracking = false
|
||||
|
||||
private let manager = CLLocationManager()
|
||||
private var filter = RouteFilter()
|
||||
private var routeBuilder: HKWorkoutRouteBuilder?
|
||||
/// Points en attente d'écriture dans HealthKit.
|
||||
private var pendingRoute: [CLLocation] = []
|
||||
/// Positions ignorées tant que la séance est en pause : le flux reste
|
||||
/// ouvert (piège 3) mais la trace ne doit pas traverser l'arrêt.
|
||||
private var isPaused = false
|
||||
|
||||
private let maxMapPoints = 1_500
|
||||
private let routeFlushThreshold = 25
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
manager.delegate = self
|
||||
authorizationStatus = manager.authorizationStatus
|
||||
}
|
||||
|
||||
// MARK: Autorisation
|
||||
|
||||
/// À appeler **au premier plan**, avant de démarrer la séance.
|
||||
func requestAuthorization() {
|
||||
// Volontairement pas `requestAlwaysAuthorization()` : voir piège 2.
|
||||
guard manager.authorizationStatus == .notDetermined else { return }
|
||||
manager.requestWhenInUseAuthorization()
|
||||
}
|
||||
|
||||
var isAuthorized: Bool {
|
||||
switch authorizationStatus {
|
||||
case .authorizedWhenInUse, .authorizedAlways: return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Cycle de vie
|
||||
|
||||
/// Démarre le suivi. **Doit être appelé pendant que l'app est au premier
|
||||
/// plan** — watchOS refuse de démarrer la localisation depuis l'arrière-plan.
|
||||
///
|
||||
/// - Parameter routeBuilder: fourni par `HKLiveWorkoutBuilder`, via
|
||||
/// `seriesBuilder(for: HKSeriesType.workoutRoute())`. `nil` pour un suivi
|
||||
/// d'affichage seul, sans écriture dans Santé.
|
||||
func start(activity: HKWorkoutActivityType, routeBuilder: HKWorkoutRouteBuilder?) {
|
||||
guard !isTracking else { return }
|
||||
// On n'exige pas que l'autorisation soit DÉJÀ accordée : quand elle est
|
||||
// encore en attente, le manager reste armé et CoreLocation délivre les
|
||||
// positions dès l'acceptation. Refuser ici priverait de trace toute
|
||||
// première séance, celle-là même où la demande apparaît.
|
||||
if authorizationStatus == .denied || authorizationStatus == .restricted {
|
||||
locationLog.error("localisation refusée par l'utilisateur : séance sans trace")
|
||||
return
|
||||
}
|
||||
|
||||
self.routeBuilder = routeBuilder
|
||||
filter = RouteFilter(maxPlausibleSpeed: Self.maxSpeed(for: activity))
|
||||
trackForMap.removeAll()
|
||||
pendingRoute.removeAll()
|
||||
distanceMeters = 0
|
||||
ascentMeters = 0
|
||||
descentMeters = 0
|
||||
isPaused = false
|
||||
|
||||
// Le défaut de watchOS est `kCLLocationAccuracyHundredMeters` — inutile
|
||||
// pour une trace. `BestForNavigation` est réservé par Apple aux
|
||||
// appareils branchés : trop gourmand pour une séance au poignet.
|
||||
manager.desiredAccuracy = kCLLocationAccuracyBest
|
||||
manager.distanceFilter = kCLDistanceFilterNone
|
||||
manager.activityType = Self.activityType(for: activity)
|
||||
|
||||
if Self.backgroundModeDeclared {
|
||||
manager.allowsBackgroundLocationUpdates = true
|
||||
} else {
|
||||
// Armer le drapeau sans la clé Info.plist tuerait l'app (piège 1).
|
||||
// Mieux vaut une trace qui s'arrête écran éteint qu'un crash.
|
||||
locationLog.error("UIBackgroundModes/location absent du plist : suivi limité au premier plan")
|
||||
}
|
||||
|
||||
manager.startUpdatingLocation()
|
||||
isTracking = true
|
||||
locationLog.info("suivi GPS démarré")
|
||||
}
|
||||
|
||||
/// Suspend l'enregistrement **sans couper le flux** (piège 3).
|
||||
func setPaused(_ paused: Bool) {
|
||||
guard isTracking else { return }
|
||||
isPaused = paused
|
||||
if paused {
|
||||
// Le point qui suivra la reprise serait à des centaines de mètres :
|
||||
// on repart d'une origine neuve pour ne pas tracer la pause.
|
||||
pendingRoute.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
/// Coupe le flux GPS. À appeler dès la fin de séance ; la trace, elle, ne
|
||||
/// se clôt qu'après la sauvegarde du workout — voir `finishRoute(with:)`.
|
||||
func stopTracking() {
|
||||
guard isTracking else { return }
|
||||
manager.stopUpdatingLocation()
|
||||
isTracking = false
|
||||
locationLog.info("suivi GPS arrete")
|
||||
}
|
||||
|
||||
/// Clôt la trace et l'associe à la séance sauvegardée.
|
||||
///
|
||||
/// ⚠️ **L'ordre n'est pas négociable.** Apple : « After saving the workout,
|
||||
/// add any remaining locations to the route builder and call finishRoute. »
|
||||
/// Appeler `finishRoute` avant `finishWorkout` empêche l'association, et ne
|
||||
/// pas l'appeler du tout fait perdre **toute** la trace — le builder est
|
||||
/// invalidé à sa libération.
|
||||
///
|
||||
/// ⚠️ `workout` peut être `nil` : `finishWorkout()` réussit mais ne rend
|
||||
/// pas l'objet **quand la montre est verrouillée** (confirmé par un
|
||||
/// ingénieur Apple). On sauvegarde alors la trace sans association plutôt
|
||||
/// que de la jeter — une route orpheline vaut mieux qu'une sortie sans
|
||||
/// parcours. Une route ne peut être associée qu'une fois, et jamais après
|
||||
/// coup.
|
||||
///
|
||||
/// Échoue aussi si aucune position n'a été insérée — cas normal d'une
|
||||
/// séance en salle, à ne pas remonter comme une anomalie.
|
||||
func finishRoute(with workout: HKWorkout?) async {
|
||||
await flushPendingRoute()
|
||||
guard let builder = routeBuilder else { return }
|
||||
self.routeBuilder = nil
|
||||
|
||||
guard filter.acceptedCount > 0 else {
|
||||
locationLog.info("aucune position retenue : pas de trace a clore")
|
||||
return
|
||||
}
|
||||
do {
|
||||
_ = try await builder.finishRoute(with: workout, metadata: nil)
|
||||
locationLog.info("trace close : \(self.filter.acceptedCount) points, \(Int(self.filter.distanceM)) m")
|
||||
} catch {
|
||||
locationLog.error("finishRoute a echoue : \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Écriture HealthKit
|
||||
|
||||
/// Écrit les points en attente. Par lots : une écriture par position
|
||||
/// gaspillerait le CPU que watchOS surveille (piège 4).
|
||||
private func flushPendingRoute() async {
|
||||
guard let builder = routeBuilder, !pendingRoute.isEmpty else { return }
|
||||
let batch = pendingRoute
|
||||
pendingRoute.removeAll()
|
||||
do {
|
||||
try await builder.insertRouteData(batch)
|
||||
} catch {
|
||||
// Les points sont perdus, pas la séance : on ne remet pas le lot
|
||||
// dans la file, sinon un échec persistant la ferait enfler
|
||||
// indéfiniment en mémoire.
|
||||
locationLog.error("insertRouteData a echoue (\(batch.count) points) : \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Réglages par activité
|
||||
|
||||
/// Plafond de vitesse plausible, en m/s. Le VTT électrique descend
|
||||
/// largement au-dessus d'un coureur : un seuil unique écarterait des points
|
||||
/// parfaitement valides.
|
||||
private static func maxSpeed(for activity: HKWorkoutActivityType) -> Double {
|
||||
switch activity {
|
||||
case .cycling: return 30 // 108 km/h, descente comprise
|
||||
case .running: return 12 // 43 km/h
|
||||
default: return 8 // marche, randonnée
|
||||
}
|
||||
}
|
||||
|
||||
private static func activityType(for activity: HKWorkoutActivityType) -> CLActivityType {
|
||||
switch activity {
|
||||
// `.fitness` fait désactiver le positionnement intérieur et peut
|
||||
// provoquer des pauses automatiques ; Apple recommande
|
||||
// `.otherNavigation` pour le vélo et le hors-route.
|
||||
case .cycling: return .otherNavigation
|
||||
default: return .fitness
|
||||
}
|
||||
}
|
||||
|
||||
/// Le plist déclare-t-il le mode de fond `location` ?
|
||||
///
|
||||
/// watchOS porte deux clés distinctes : `WKBackgroundModes` pour
|
||||
/// `workout-processing`, et `UIBackgroundModes` pour `location`. On accepte
|
||||
/// les deux, la documentation d'Apple n'étant pas univoque sur ce point.
|
||||
private static var backgroundModeDeclared: Bool {
|
||||
let keys = ["UIBackgroundModes", "WKBackgroundModes"]
|
||||
for key in keys {
|
||||
if let modes = Bundle.main.object(forInfoDictionaryKey: key) as? [String],
|
||||
modes.contains("location") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
extension LocationTracker: CLLocationManagerDelegate {
|
||||
nonisolated func locationManager(_ manager: CLLocationManager,
|
||||
didUpdateLocations locations: [CLLocation]) {
|
||||
let fixes = locations.map { GeoFix(location: $0) }
|
||||
let raw = locations
|
||||
Task { @MainActor in
|
||||
self.ingest(fixes: fixes, raw: raw)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func locationManager(_ manager: CLLocationManager,
|
||||
didFailWithError error: Error) {
|
||||
locationLog.error("CoreLocation a echoue : \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||
let status = manager.authorizationStatus
|
||||
Task { @MainActor in
|
||||
self.authorizationStatus = status
|
||||
}
|
||||
}
|
||||
|
||||
private func ingest(fixes: [GeoFix], raw: [CLLocation]) {
|
||||
horizontalAccuracy = fixes.last?.horizontalAccuracy
|
||||
guard !isPaused else { return }
|
||||
|
||||
for (fix, location) in zip(fixes, raw) {
|
||||
guard case .accepted = filter.add(fix) else { continue }
|
||||
lastFix = fix
|
||||
pendingRoute.append(location)
|
||||
appendToMap(fix)
|
||||
}
|
||||
|
||||
distanceMeters = filter.distanceM
|
||||
ascentMeters = filter.ascentM
|
||||
descentMeters = filter.descentM
|
||||
|
||||
if pendingRoute.count >= routeFlushThreshold {
|
||||
Task { await flushPendingRoute() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Ajoute à la trace d'affichage, en décimant une fois le plafond atteint :
|
||||
/// on garde un point sur deux, ce qui divise la densité sans déformer le
|
||||
/// tracé ni faire enfler la mémoire sur une longue sortie.
|
||||
private func appendToMap(_ fix: GeoFix) {
|
||||
trackForMap.append(fix)
|
||||
if trackForMap.count > maxMapPoints {
|
||||
trackForMap = trackForMap.enumerated()
|
||||
.compactMap { $0.offset.isMultiple(of: 2) ? $0.element : nil }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension GeoFix {
|
||||
/// Conversion depuis CoreLocation. Isolée ici pour que `RouteFilter` reste
|
||||
/// testable hors d'un Mac.
|
||||
init(location: CLLocation) {
|
||||
self.init(lat: location.coordinate.latitude,
|
||||
lon: location.coordinate.longitude,
|
||||
altitude: location.altitude,
|
||||
horizontalAccuracy: location.horizontalAccuracy,
|
||||
verticalAccuracy: location.verticalAccuracy,
|
||||
speed: location.speed,
|
||||
timestamp: location.timestamp)
|
||||
}
|
||||
}
|
||||
211
ios/App/CoachWatch/RouteFilter.swift
Normal file
211
ios/App/CoachWatch/RouteFilter.swift
Normal file
@@ -0,0 +1,211 @@
|
||||
import Foundation
|
||||
|
||||
/* Filtrage de la trace GPS et dénivelé, en Foundation pur.
|
||||
*
|
||||
* **Pourquoi ce fichier est séparé de CoreLocation.** `CLLocation` n'existe pas
|
||||
* sur Linux : tant que le filtrage vit dans la classe qui parle au GPS, il ne
|
||||
* se teste que sur un poignet, en marchant. Ici, `GeoFix` recopie les seuls
|
||||
* champs dont le filtrage a besoin ; `LocationTracker` fait la conversion et
|
||||
* reste, lui, non testable hors Xcode.
|
||||
*
|
||||
* **Ce que ce fichier NE fait PAS.** Il ne produit pas la distance officielle
|
||||
* de la séance. HealthKit collecte `distanceWalkingRunning` / `distanceCycling`
|
||||
* par fusion GPS + accéléromètre, et fait autorité — c'est déjà ce que lit
|
||||
* `WorkoutManager`. La distance calculée ici sert à la carte et au découpage
|
||||
* des étapes en distance, où l'on a besoin d'une valeur à chaque point plutôt
|
||||
* qu'à chaque échantillon HealthKit.
|
||||
*/
|
||||
|
||||
/// Un point GPS, réduit à ce dont le filtrage a besoin.
|
||||
public struct GeoFix: Equatable, Sendable {
|
||||
public var lat: Double
|
||||
public var lon: Double
|
||||
/// Mètres au-dessus du niveau de la mer.
|
||||
public var altitude: Double
|
||||
/// Rayon d'incertitude horizontale en mètres. **Négatif = fix invalide**,
|
||||
/// convention Apple.
|
||||
public var horizontalAccuracy: Double
|
||||
/// Incertitude verticale. Négatif = altitude non fiable.
|
||||
public var verticalAccuracy: Double
|
||||
/// Vitesse instantanée en m/s. **Négatif = inconnue**, convention Apple.
|
||||
public var speed: Double
|
||||
public var timestamp: Date
|
||||
|
||||
public init(lat: Double, lon: Double, altitude: Double = 0,
|
||||
horizontalAccuracy: Double = 5, verticalAccuracy: Double = 5,
|
||||
speed: Double = -1, timestamp: Date) {
|
||||
self.lat = lat
|
||||
self.lon = lon
|
||||
self.altitude = altitude
|
||||
self.horizontalAccuracy = horizontalAccuracy
|
||||
self.verticalAccuracy = verticalAccuracy
|
||||
self.speed = speed
|
||||
self.timestamp = timestamp
|
||||
}
|
||||
}
|
||||
|
||||
/// Pourquoi un point a été écarté. Renvoyé plutôt que jeté en silence : sur le
|
||||
/// terrain, « la trace est trouée » sans plus d'explication ne se diagnostique
|
||||
/// pas — la répartition des motifs, elle, se lit.
|
||||
public enum FixRejection: String, Equatable, Sendable {
|
||||
case invalidAccuracy // horizontalAccuracy <= 0 : fix inutilisable
|
||||
case tooImprecise // au-delà du rayon toléré
|
||||
case outOfOrder // arrivé après un point plus récent
|
||||
case duplicate // même horodatage que le précédent
|
||||
case implausibleSpeed // saut impossible entre deux points
|
||||
}
|
||||
|
||||
public enum FixOutcome: Equatable, Sendable {
|
||||
case accepted
|
||||
case rejected(FixRejection)
|
||||
}
|
||||
|
||||
/// Accumule une trace propre : filtre les points douteux, cumule distance et
|
||||
/// dénivelé.
|
||||
///
|
||||
/// `struct` mutable et sans horloge interne, comme `IntervalEngine` : rejouer
|
||||
/// la même suite de points redonne le même résultat.
|
||||
public struct RouteFilter: Equatable, Sendable {
|
||||
|
||||
/// Rayon d'incertitude maximum toléré, en mètres.
|
||||
///
|
||||
/// 50 m est le seuil retenu par l'exemple de trace d'Apple. Plus strict
|
||||
/// perdrait les premières secondes sous couvert forestier ; plus laxiste
|
||||
/// laisserait entrer les points « sautés » qui gonflent la distance.
|
||||
public var maxHorizontalAccuracy: Double
|
||||
|
||||
/// Vitesse au-delà de laquelle un déplacement entre deux points est jugé
|
||||
/// impossible, en m/s. 30 m/s ≈ 108 km/h : au-dessus, c'est une
|
||||
/// téléportation GPS ou un trajet en voiture, pas une séance.
|
||||
public var maxPlausibleSpeed: Double
|
||||
|
||||
/// Écart vertical à franchir avant de compter une montée ou une descente.
|
||||
///
|
||||
/// ⚠️ Sans ce seuil, le bruit de l'altimètre s'accumule : quelques
|
||||
/// dizaines de centimètres d'oscillation à chaque point produisent des
|
||||
/// centaines de mètres de D+ sur une sortie plate. L'hystérésis ne compte
|
||||
/// que les changements de sens confirmés.
|
||||
///
|
||||
/// ⚠️ **Le seuil seul ne suffit pas** — mesuré en test : un bruit alternant
|
||||
/// de ±1,5 m, soit exactement l'amplitude du seuil, était compté à chaque
|
||||
/// alternance et produisait 297 m de D+ sur un parcours plat. C'est
|
||||
/// inhérent à tout seuil : une oscillation d'amplitude ≥ seuil passe. D'où
|
||||
/// le lissage préalable ci-dessous, qui est le vrai remède.
|
||||
public var elevationThreshold: Double
|
||||
|
||||
/// Nombre de points de la moyenne glissante appliquée à l'altitude avant
|
||||
/// l'hystérésis. `1` désactive le lissage.
|
||||
///
|
||||
/// Le bruit alternant s'annule dans la moyenne, une vraie pente la
|
||||
/// traverse. Contrepartie assumée : la moyenne **retarde** le signal d'une
|
||||
/// demi-fenêtre, ce qui ampute le D+ de quelques mètres en fin de trace.
|
||||
/// Sans conséquence ici — ce D+ sert l'affichage pendant la séance, alors
|
||||
/// que la valeur qui fait foi est le D+ barométrique d'Apple, relu après
|
||||
/// coup depuis les métadonnées HealthKit.
|
||||
public var elevationSmoothing: Int
|
||||
|
||||
public private(set) var distanceM: Double = 0
|
||||
public private(set) var ascentM: Double = 0
|
||||
public private(set) var descentM: Double = 0
|
||||
public private(set) var acceptedCount: Int = 0
|
||||
public private(set) var rejectedCount: Int = 0
|
||||
public private(set) var lastAccepted: GeoFix?
|
||||
|
||||
/// Altitude de référence de l'hystérésis.
|
||||
private var pivotAltitude: Double?
|
||||
/// Fenêtre glissante des altitudes retenues.
|
||||
private var altWindow: [Double] = []
|
||||
|
||||
public init(maxHorizontalAccuracy: Double = 50,
|
||||
maxPlausibleSpeed: Double = 30,
|
||||
elevationThreshold: Double = 3,
|
||||
elevationSmoothing: Int = 5) {
|
||||
self.maxHorizontalAccuracy = maxHorizontalAccuracy
|
||||
self.maxPlausibleSpeed = maxPlausibleSpeed
|
||||
self.elevationThreshold = elevationThreshold
|
||||
self.elevationSmoothing = max(1, elevationSmoothing)
|
||||
}
|
||||
|
||||
/// Soumet un point. Renvoie s'il a été retenu, et pourquoi sinon.
|
||||
@discardableResult
|
||||
public mutating func add(_ fix: GeoFix) -> FixOutcome {
|
||||
if fix.horizontalAccuracy <= 0 {
|
||||
rejectedCount += 1
|
||||
return .rejected(.invalidAccuracy)
|
||||
}
|
||||
if fix.horizontalAccuracy > maxHorizontalAccuracy {
|
||||
rejectedCount += 1
|
||||
return .rejected(.tooImprecise)
|
||||
}
|
||||
|
||||
if let previous = lastAccepted {
|
||||
let dt = fix.timestamp.timeIntervalSince(previous.timestamp)
|
||||
if dt == 0 {
|
||||
rejectedCount += 1
|
||||
return .rejected(.duplicate)
|
||||
}
|
||||
if dt < 0 {
|
||||
// CoreLocation peut livrer des points dans le désordre après
|
||||
// une reprise ; les intégrer ferait reculer la distance.
|
||||
rejectedCount += 1
|
||||
return .rejected(.outOfOrder)
|
||||
}
|
||||
let step = Self.distance(from: previous, to: fix)
|
||||
if step / dt > maxPlausibleSpeed {
|
||||
rejectedCount += 1
|
||||
return .rejected(.implausibleSpeed)
|
||||
}
|
||||
distanceM += step
|
||||
}
|
||||
|
||||
accumulateElevation(fix)
|
||||
lastAccepted = fix
|
||||
acceptedCount += 1
|
||||
return .accepted
|
||||
}
|
||||
|
||||
/// Distance orthodromique entre deux points, en mètres (formule de
|
||||
/// haversine). Volontairement 2D : c'est la convention de toutes les
|
||||
/// plateformes, et intégrer la pente gonflerait la distance par rapport à
|
||||
/// ce qu'affichent Santé, Strava ou la montre.
|
||||
public static func distance(from a: GeoFix, to b: GeoFix) -> Double {
|
||||
// Rayon moyen terrestre WGS84.
|
||||
let radius = 6_371_008.8
|
||||
let φ1 = a.lat * .pi / 180
|
||||
let φ2 = b.lat * .pi / 180
|
||||
let dφ = (b.lat - a.lat) * .pi / 180
|
||||
let dλ = (b.lon - a.lon) * .pi / 180
|
||||
let h = sin(dφ / 2) * sin(dφ / 2)
|
||||
+ cos(φ1) * cos(φ2) * sin(dλ / 2) * sin(dλ / 2)
|
||||
return 2 * radius * asin(min(1, sqrt(h)))
|
||||
}
|
||||
|
||||
/// Hystérésis du dénivelé : on ne valide un sens qu'après avoir franchi le
|
||||
/// seuil depuis le dernier point de bascule.
|
||||
private mutating func accumulateElevation(_ fix: GeoFix) {
|
||||
// Une altitude sans incertitude fiable ne doit pas nourrir le D+ : le
|
||||
// GPS seul dérive de plusieurs mètres, l'altimètre barométrique non.
|
||||
guard fix.verticalAccuracy > 0 else { return }
|
||||
|
||||
// Lissage d'abord : le bruit alternant s'annule dans la moyenne. On
|
||||
// n'attend pas que la fenêtre soit pleine — sinon les premiers points
|
||||
// seraient perdus, et avec eux le début de la première montée.
|
||||
altWindow.append(fix.altitude)
|
||||
if altWindow.count > elevationSmoothing { altWindow.removeFirst() }
|
||||
let smoothed = altWindow.reduce(0, +) / Double(altWindow.count)
|
||||
|
||||
guard let pivot = pivotAltitude else {
|
||||
pivotAltitude = smoothed
|
||||
return
|
||||
}
|
||||
let delta = smoothed - pivot
|
||||
if delta >= elevationThreshold {
|
||||
ascentM += delta
|
||||
pivotAltitude = smoothed
|
||||
} else if delta <= -elevationThreshold {
|
||||
descentM += -delta
|
||||
pivotAltitude = smoothed
|
||||
}
|
||||
// Entre les deux : oscillation, on ne bouge pas le pivot.
|
||||
}
|
||||
}
|
||||
@@ -49,8 +49,18 @@ final class WorkoutManager: NSObject, ObservableObject {
|
||||
// ne la produit pas, les stats restent vides : `ingest` retombe alors
|
||||
// sur la vitesse moyenne dérivée de la distance.
|
||||
let speedType = Self.speedType(for: activityType)
|
||||
let read: Set<HKObjectType> = [hr, energy, distanceType, speedType]
|
||||
let share: Set<HKSampleType> = [HKQuantityType.workoutType(), hr, energy, distanceType]
|
||||
// La trace exige d'être demandée en lecture ET en écriture — Apple :
|
||||
// « you must request permission to read and share both HKWorkout and
|
||||
// HKWorkoutRoute samples ». Sans elle, `finishRoute` échoue et la
|
||||
// sortie n'a pas de parcours.
|
||||
let route = HKSeriesType.workoutRoute()
|
||||
let outdoor = (locationType == .outdoor)
|
||||
var read: Set<HKObjectType> = [hr, energy, distanceType, speedType]
|
||||
var share: Set<HKSampleType> = [HKQuantityType.workoutType(), hr, energy, distanceType]
|
||||
if outdoor {
|
||||
read.insert(route)
|
||||
share.insert(route)
|
||||
}
|
||||
|
||||
do {
|
||||
try await healthStore.requestAuthorization(toShare: share, read: read)
|
||||
@@ -66,6 +76,12 @@ final class WorkoutManager: NSObject, ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
// Demandée ici, donc au premier plan : watchOS n'affiche pas la feuille
|
||||
// d'autorisation depuis l'arrière-plan. Volontairement « quand
|
||||
// l'app est active » et jamais « toujours » — sur watchOS ce dernier
|
||||
// mène à un parcours d'autorisation qui boucle.
|
||||
if outdoor { LocationTracker.shared.requestAuthorization() }
|
||||
|
||||
let config = HKWorkoutConfiguration()
|
||||
config.activityType = activityType
|
||||
config.locationType = locationType
|
||||
@@ -87,6 +103,19 @@ final class WorkoutManager: NSObject, ObservableObject {
|
||||
isRunning = true
|
||||
statusMessage = nil
|
||||
workoutLog.info("beginCollection OK, isRunning=true")
|
||||
|
||||
// GPS uniquement en extérieur : en salle il ne ferait que vider la
|
||||
// batterie pour une trace immobile.
|
||||
if outdoor {
|
||||
// Apple déconseille d'instancier un HKWorkoutRouteBuilder
|
||||
// directement : celui du builder de séance associe la trace.
|
||||
let routeBuilder = builder.seriesBuilder(for: HKSeriesType.workoutRoute())
|
||||
as? HKWorkoutRouteBuilder
|
||||
if routeBuilder == nil {
|
||||
workoutLog.error("seriesBuilder n'a pas rendu un HKWorkoutRouteBuilder")
|
||||
}
|
||||
LocationTracker.shared.start(activity: activityType, routeBuilder: routeBuilder)
|
||||
}
|
||||
} catch {
|
||||
self.session = nil
|
||||
self.builder = nil
|
||||
@@ -264,14 +293,26 @@ extension WorkoutManager: HKWorkoutSessionDelegate {
|
||||
let paused = (toState == .paused)
|
||||
Task { @MainActor in
|
||||
self.isPaused = paused
|
||||
// Le flux GPS reste ouvert pendant la pause : watchOS interdit
|
||||
// de redémarrer la localisation depuis l'arrière-plan, donc on
|
||||
// ignore les points au lieu de couper le manager.
|
||||
LocationTracker.shared.setPaused(paused)
|
||||
ConnectivityManager.shared.sendPaused(paused)
|
||||
}
|
||||
}
|
||||
guard toState == .ended else { return }
|
||||
let builder = workoutSession.associatedWorkoutBuilder()
|
||||
Task {
|
||||
Task { @MainActor in
|
||||
// Ordre imposé par Apple : couper le GPS, clore la collecte,
|
||||
// sauvegarder la séance, et SEULEMENT ensuite clore la trace en
|
||||
// l'associant au workout obtenu.
|
||||
LocationTracker.shared.stopTracking()
|
||||
try? await builder.endCollection(at: date)
|
||||
_ = try? await builder.finishWorkout()
|
||||
// ⚠️ `finishWorkout()` rend `nil` SANS erreur quand la montre est
|
||||
// verrouillée : ce n'est pas un échec. On poursuit avec `nil`, la
|
||||
// trace sera sauvegardée sans association plutôt que perdue.
|
||||
let workout = try? await builder.finishWorkout()
|
||||
await LocationTracker.shared.finishRoute(with: workout)
|
||||
}
|
||||
Task { @MainActor in
|
||||
self.isRunning = false
|
||||
|
||||
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
|
||||
1
tests-linux/Sources/CoachModel/RouteFilter.swift
Symbolic link
1
tests-linux/Sources/CoachModel/RouteFilter.swift
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../ios/App/CoachWatch/RouteFilter.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")
|
||||
}
|
||||
}
|
||||
206
tests-linux/Tests/CoachModelTests/RouteFilterTests.swift
Normal file
206
tests-linux/Tests/CoachModelTests/RouteFilterTests.swift
Normal file
@@ -0,0 +1,206 @@
|
||||
import XCTest
|
||||
@testable import CoachModel
|
||||
|
||||
/* Filtrage de la trace GPS.
|
||||
*
|
||||
* Les cas testés ne sont pas théoriques : ce sont les quatre façons dont une
|
||||
* trace se salit réellement — acquisition GPS des premières secondes, couvert
|
||||
* forestier, points livrés dans le désordre après une reprise, et bruit
|
||||
* d'altimètre sur terrain plat.
|
||||
*/
|
||||
final class RouteFilterTests: XCTestCase {
|
||||
|
||||
private let t0 = Date(timeIntervalSince1970: 1_755_000_000)
|
||||
|
||||
private func fix(_ lat: Double, _ lon: Double,
|
||||
at seconds: TimeInterval,
|
||||
alt: Double = 600,
|
||||
hAcc: Double = 5,
|
||||
vAcc: Double = 3) -> GeoFix {
|
||||
GeoFix(lat: lat, lon: lon, altitude: alt,
|
||||
horizontalAccuracy: hAcc, verticalAccuracy: vAcc,
|
||||
speed: -1, timestamp: t0.addingTimeInterval(seconds))
|
||||
}
|
||||
|
||||
// MARK: Distance
|
||||
|
||||
func testHaversineMatchesAKnownDistance() {
|
||||
// Blonay → Vevey, deux points relevés sur la carte. La distance réelle
|
||||
// à vol d'oiseau est d'environ 3,4 km.
|
||||
let blonay = fix(46.4667, 6.9167, at: 0)
|
||||
let vevey = fix(46.4628, 6.8419, at: 60)
|
||||
let d = RouteFilter.distance(from: blonay, to: vevey)
|
||||
XCTAssertEqual(d, 5760, accuracy: 200,
|
||||
"Écart Blonay–Vevey en longitude : ~5,8 km")
|
||||
}
|
||||
|
||||
func testDistanceIsZeroForTheSamePoint() {
|
||||
let a = fix(46.4667, 6.9167, at: 0)
|
||||
XCTAssertEqual(RouteFilter.distance(from: a, to: a), 0, accuracy: 0.0001)
|
||||
}
|
||||
|
||||
func testDistanceAccumulatesAlongATrack() {
|
||||
var filter = RouteFilter()
|
||||
// Cinq points espacés d'environ 100 m vers l'est.
|
||||
for i in 0..<5 {
|
||||
let lon = 6.9167 + Double(i) * 0.0013
|
||||
XCTAssertEqual(filter.add(fix(46.4667, lon, at: Double(i) * 30)), .accepted)
|
||||
}
|
||||
XCTAssertEqual(filter.acceptedCount, 5)
|
||||
XCTAssertEqual(filter.distanceM, 400, accuracy: 40)
|
||||
}
|
||||
|
||||
// MARK: Rejets
|
||||
|
||||
func testFixWithNegativeAccuracyIsRejected() {
|
||||
var filter = RouteFilter()
|
||||
// Convention Apple : une précision négative signale un fix inutilisable.
|
||||
XCTAssertEqual(filter.add(fix(46.4667, 6.9167, at: 0, hAcc: -1)),
|
||||
.rejected(.invalidAccuracy))
|
||||
XCTAssertEqual(filter.acceptedCount, 0)
|
||||
XCTAssertEqual(filter.distanceM, 0)
|
||||
}
|
||||
|
||||
func testImpreciseFixesAreRejected() {
|
||||
var filter = RouteFilter()
|
||||
// Les premières secondes d'une séance : le GPS n'a pas encore convergé.
|
||||
XCTAssertEqual(filter.add(fix(46.4667, 6.9167, at: 0, hAcc: 165)),
|
||||
.rejected(.tooImprecise))
|
||||
XCTAssertEqual(filter.add(fix(46.4667, 6.9167, at: 3, hAcc: 65)),
|
||||
.rejected(.tooImprecise))
|
||||
XCTAssertEqual(filter.add(fix(46.4667, 6.9167, at: 8, hAcc: 12)), .accepted)
|
||||
XCTAssertEqual(filter.rejectedCount, 2)
|
||||
XCTAssertEqual(filter.acceptedCount, 1)
|
||||
}
|
||||
|
||||
func testTeleportationIsRejected() {
|
||||
var filter = RouteFilter()
|
||||
XCTAssertEqual(filter.add(fix(46.4667, 6.9167, at: 0)), .accepted)
|
||||
// Point sauté de ~5,8 km en une seconde : impossible à pied.
|
||||
XCTAssertEqual(filter.add(fix(46.4628, 6.8419, at: 1)),
|
||||
.rejected(.implausibleSpeed))
|
||||
XCTAssertEqual(filter.distanceM, 0, accuracy: 0.001,
|
||||
"Un saut GPS ne doit pas gonfler la distance")
|
||||
// Le point suivant, plausible, repart du dernier point ACCEPTÉ.
|
||||
XCTAssertEqual(filter.add(fix(46.4667, 6.9180, at: 10)), .accepted)
|
||||
XCTAssertEqual(filter.distanceM, 100, accuracy: 20)
|
||||
}
|
||||
|
||||
func testOutOfOrderAndDuplicateFixesAreRejected() {
|
||||
var filter = RouteFilter()
|
||||
XCTAssertEqual(filter.add(fix(46.4667, 6.9167, at: 100)), .accepted)
|
||||
XCTAssertEqual(filter.add(fix(46.4667, 6.9168, at: 100)), .rejected(.duplicate))
|
||||
XCTAssertEqual(filter.add(fix(46.4667, 6.9168, at: 90)), .rejected(.outOfOrder))
|
||||
XCTAssertEqual(filter.acceptedCount, 1)
|
||||
}
|
||||
|
||||
// MARK: Dénivelé
|
||||
|
||||
func testFlatNoisyTerrainProducesNoElevationGain() {
|
||||
// Le piège central : ±1,5 m de bruit d'altimètre sur 200 points. Sans
|
||||
// hystérésis, on cumulerait des centaines de mètres de D+ sur du plat.
|
||||
var filter = RouteFilter()
|
||||
for i in 0..<200 {
|
||||
let noise = (i % 2 == 0) ? 1.5 : -1.5
|
||||
_ = filter.add(fix(46.4667, 6.9167 + Double(i) * 0.0002,
|
||||
at: Double(i) * 5, alt: 600 + noise))
|
||||
}
|
||||
XCTAssertEqual(filter.ascentM, 0, accuracy: 0.001,
|
||||
"Le bruit d'altimètre ne doit pas produire de D+")
|
||||
XCTAssertEqual(filter.descentM, 0, accuracy: 0.001)
|
||||
}
|
||||
|
||||
func testRealClimbIsCounted() {
|
||||
// Cadence réaliste : un point par seconde, +0,5 m par point — soit une
|
||||
// montée de 100 m en 3 min 20, ce qui est déjà raide.
|
||||
var filter = RouteFilter()
|
||||
for i in 0...200 {
|
||||
_ = filter.add(fix(46.4667, 6.9167 + Double(i) * 0.00005,
|
||||
at: Double(i), alt: 600 + Double(i) * 0.5))
|
||||
}
|
||||
XCTAssertEqual(filter.ascentM, 100, accuracy: 5)
|
||||
XCTAssertEqual(filter.descentM, 0, accuracy: 0.001)
|
||||
}
|
||||
|
||||
func testDescentIsCountedSeparately() {
|
||||
var filter = RouteFilter()
|
||||
for i in 0...200 { // 600 → 700
|
||||
_ = filter.add(fix(46.4667, 6.9167 + Double(i) * 0.00005,
|
||||
at: Double(i), alt: 600 + Double(i) * 0.5))
|
||||
}
|
||||
for i in 1...200 { // 700 → 600
|
||||
_ = filter.add(fix(46.4667, 6.9267 + Double(i) * 0.00005,
|
||||
at: 200 + Double(i), alt: 700 - Double(i) * 0.5))
|
||||
}
|
||||
XCTAssertEqual(filter.ascentM, 100, accuracy: 5)
|
||||
XCTAssertEqual(filter.descentM, 100, accuracy: 5)
|
||||
}
|
||||
|
||||
func testSmoothingClipsASharpPeak() {
|
||||
// Comportement assumé, pas un bug : une pointe franchie en quelques
|
||||
// points est arrondie par la moyenne glissante, donc sous-comptée. Ce
|
||||
// test existe pour que la perte soit connue et surveillée — le D+ qui
|
||||
// fait foi reste celui, barométrique, qu'Apple écrit dans les
|
||||
// métadonnées de la séance.
|
||||
var filter = RouteFilter()
|
||||
for i in 0...10 {
|
||||
_ = filter.add(fix(46.4667, 6.9167 + Double(i) * 0.0005,
|
||||
at: Double(i) * 20, alt: 600 + Double(i) * 10))
|
||||
}
|
||||
for i in 1...10 {
|
||||
_ = filter.add(fix(46.4667, 6.9217 + Double(i) * 0.0005,
|
||||
at: 200 + Double(i) * 20, alt: 700 - Double(i) * 10))
|
||||
}
|
||||
XCTAssertLessThan(filter.ascentM, 100)
|
||||
XCTAssertLessThan(filter.descentM, 100)
|
||||
XCTAssertGreaterThan(filter.ascentM, 60,
|
||||
"L'écrêtage doit rester partiel, pas effacer la montée")
|
||||
}
|
||||
|
||||
func testAltitudeWithoutVerticalAccuracyIsIgnored() {
|
||||
// Le GPS seul dérive verticalement de plusieurs mètres. Apple signale
|
||||
// une altitude non fiable par une incertitude négative.
|
||||
var filter = RouteFilter()
|
||||
for i in 0...20 {
|
||||
_ = filter.add(fix(46.4667, 6.9167 + Double(i) * 0.0005,
|
||||
at: Double(i) * 20,
|
||||
alt: 600 + Double(i) * 5, vAcc: -1))
|
||||
}
|
||||
XCTAssertEqual(filter.ascentM, 0, accuracy: 0.001)
|
||||
XCTAssertEqual(filter.acceptedCount, 21,
|
||||
"Le point reste valide pour la trace, seule l'altitude est écartée")
|
||||
}
|
||||
|
||||
// MARK: Robustesse
|
||||
|
||||
func testFirstFixEstablishesTheOriginWithoutDistance() {
|
||||
var filter = RouteFilter()
|
||||
XCTAssertEqual(filter.add(fix(46.4667, 6.9167, at: 0)), .accepted)
|
||||
XCTAssertEqual(filter.distanceM, 0)
|
||||
XCTAssertNotNil(filter.lastAccepted)
|
||||
}
|
||||
|
||||
func testReplayingTheSameTrackGivesTheSameResult() {
|
||||
let track = (0..<50).map { i in
|
||||
fix(46.4667 + Double(i) * 0.0001, 6.9167 + Double(i) * 0.0003,
|
||||
at: Double(i) * 4, alt: 600 + Double(i) * 2)
|
||||
}
|
||||
var a = RouteFilter(), b = RouteFilter()
|
||||
for f in track { a.add(f) }
|
||||
for f in track { b.add(f) }
|
||||
XCTAssertEqual(a, b)
|
||||
}
|
||||
|
||||
func testThresholdsAreConfigurable() {
|
||||
// Une sortie à VTT électrique dépasse allègrement les 30 km/h : le
|
||||
// seuil doit pouvoir s'ajuster sans toucher au filtre.
|
||||
var strict = RouteFilter(maxPlausibleSpeed: 5)
|
||||
strict.add(fix(46.4667, 6.9167, at: 0))
|
||||
XCTAssertEqual(strict.add(fix(46.4667, 6.9180, at: 1)),
|
||||
.rejected(.implausibleSpeed))
|
||||
|
||||
var loose = RouteFilter(maxPlausibleSpeed: 200)
|
||||
loose.add(fix(46.4667, 6.9167, at: 0))
|
||||
XCTAssertEqual(loose.add(fix(46.4667, 6.9180, at: 1)), .accepted)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user