Les bornes cardiaques partaient vers la montre sans que rien ne dise si elles arrivaient

§ 1.2 de l'audit, vérifié le matin même puis corrigé sur demande explicite de
Sylvain — le gel du 22/09 est levé pour ce point seul, les quatre autres
constats restent intouchés.

Pourquoi celui-là d'abord : c'est le seul dont la conséquence est un chiffre
faux affiché PENDANT l'effort, chez quelqu'un qui règle son intensité dessus.
Les autres perdent de la donnée ou de la batterie, ça se rattrape. Une borne de
zone périmée au poignet a déjà servi à décider.

**L'envoi ne se tait plus.** `pushToWatch` avait quatre sorties silencieuses sur
sept lignes ; il en a maintenant six, toutes nommées et journalisées —
WatchConnectivity non supportée, session non activée, aucune montre appairée,
app Watch non installée, encodage impossible, envoi refusé. Les deux gardes du
milieu viennent de `CoachRoutineBridge`, qui les avait déjà : sans elles,
l'absence de montre se confond avec un échec d'envoi. Le log de succès nomme ce
qui compte — `zones=oui depuis=2026-08-12` — pour savoir quelles bornes sont au
poignet sans ouvrir la montre.

⚠️ Un échec ne fait PAS échouer `setSnapshot`, contrairement à
`CoachRoutineBridge` où l'envoi EST l'opération. Ici l'App Group est déjà écrit
et les widgets iPhone rafraîchis : une montre verrouillée n'invalide rien. D'où
un `resolve` enrichi (`watchPushed`, `watchReason`) plutôt qu'un `reject`, et
`ok` conservé — aucun appelant JS ne casse.

**La date de version voyage enfin.** `HeartRateZones` ne portait aucune date,
alors que le backend sert une version figée et datée (règle 9 du CLAUDE.md web) :
elle se perdait au transport, et après une révision rien au poignet ne
distinguait les nouvelles bornes des anciennes. `zonesSince` et `zonesFrom`
rejoignent le modèle en optionnels. Le serveur les servait déjà — c'est le JS du
dépôt web qui ne les faisait pas monter.

Trois tests au banc Linux : la date survit à `asOf()`, elle survit à
l'aller-retour JSON (ce qui n'est pas encodé n'arrive jamais), et un snapshot
antérieur à ces champs se décode toujours — un décodage qui lèverait laisserait
la montre sans zones du tout, pire que des zones sans date. 91 verts (88 avant).

⛔ NON COMPILÉ. Syntaxe vérifiée (`swiftc -parse`), mais Capacitor,
WatchConnectivity et SwiftUI n'existent pas sur Linux. Au prochain build :
ouvrir la Console sur la catégorie `widget` et vérifier qu'une ligne
« snapshot poussé vers la Watch · zones=oui depuis=… » apparaît au lancement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Bettinelli
2026-09-24 08:08:02 +00:00
parent 8e0c2738ce
commit 7e7be4a1d1
5 changed files with 194 additions and 11 deletions

View File

@@ -43,7 +43,8 @@ final class DayRolloverTests: XCTestCase {
s.kcalToday = 1186
s.waterGoalMl = 2750
s.kcalGoal = 1950
s.zones = HeartRateZones(z2: [109, 118], fcmaxUsed: 152, clinicalTargetBpm: 112)
s.zones = HeartRateZones(z2: [109, 118], fcmaxUsed: 152, clinicalTargetBpm: 112,
zonesSince: "2026-08-12", zonesFrom: "frozen")
return s
}
@@ -94,6 +95,42 @@ final class DayRolloverTests: XCTestCase {
"les zones sont une version figée, valable des semaines")
}
/// La date de version des zones doit survivre au transport et au passage de
/// minuit — sans elle, la montre affiche des bornes sans âge, et après une
/// révision rien ne distingue les nouvelles des anciennes.
/// Cf. `docs/DIAGNOSTIC-NATIF-2026-09-24.md` § 1.2.
func testZonesCarryTheirVersionDate() {
let vu = snapshot(at: "2026-08-10 12:00").asOf(date("2026-08-18 09:00"), calendar: zurich)
XCTAssertEqual(vu.zones?.zonesSince, "2026-08-12",
"la date de la version figée se perdait au transport")
XCTAssertEqual(vu.zones?.zonesFrom, "frozen",
"« figée » et « provisoire » ne doivent pas se confondre au poignet")
}
/// Le snapshot voyage en JSON jusqu'à la montre : ce qui n'est pas encodé
/// n'arrive pas. Le test précédent vérifie la recopie en mémoire, celui-ci
/// le passage par le fil.
func testVersionDateSurvivesJsonRoundTrip() throws {
let original = snapshot(at: "2026-08-10 12:00")
let data = try JSONEncoder().encode(original)
let relu = try JSONDecoder().decode(CoachWidgetSnapshot.self, from: data)
XCTAssertEqual(relu.zones?.zonesSince, "2026-08-12")
XCTAssertEqual(relu.zones?.zonesFrom, "frozen")
}
/// ⚠️ Un snapshot écrit AVANT l'ajout de ces champs doit rester décodable :
/// la montre en garde un en cache, et un décodage qui lève la laisserait
/// sans zones du tout — pire que des zones sans date.
func testOlderSnapshotWithoutVersionDateStillDecodes() throws {
// `updatedAt` est un `Date` : l'encodeur par défaut l'écrit en secondes
// depuis la date de référence, pas en ISO — comme les autres tests de
// compatibilité ascendante de ce banc (cf. QuickDrinkTests).
let json = #"{"updatedAt": 1755000000, "zones": {"z2": [109, 118], "fcmaxUsed": 152}}"#
let relu = try JSONDecoder().decode(CoachWidgetSnapshot.self, from: Data(json.utf8))
XCTAssertEqual(relu.zones?.fcmaxUsed, 152)
XCTAssertNil(relu.zones?.zonesSince, "absente, pas inventée")
}
// MARK: - Au-delà du lendemain : ne rien inventer
func testAfterTwoDaysTheSessionIsUnknown() {