§ 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>
181 lines
8.6 KiB
Swift
181 lines
8.6 KiB
Swift
// Le widget doit passer minuit tout seul.
|
||
//
|
||
// ⚠️ Signalé par Sylvain le 2026-08-18 : passé minuit, l'hydratation, les
|
||
// calories et la séance de la veille restaient affichées comme celles du jour.
|
||
// Le widget n'a AUCUN accès réseau — il rend ce que l'app lui a poussé la
|
||
// dernière fois qu'elle a tourné. `updatedAt` existait depuis toujours ;
|
||
// personne ne le lisait.
|
||
//
|
||
// ⚠️ Ces tests tournent sur **Linux**, donc sur la logique pure uniquement.
|
||
// WidgetKit et SwiftUI n'y existent pas : les vues et les providers ne peuvent
|
||
// être vérifiés QUE par un build Xcode. Ce que ce fichier garde, c'est
|
||
// l'arithmétique de dates — là où un décalage d'un jour se glisse sans bruit.
|
||
import XCTest
|
||
@testable import CoachModel
|
||
|
||
final class DayRolloverTests: XCTestCase {
|
||
|
||
/// Fuseau de l'utilisateur. ⚠️ Explicite, jamais `Calendar.current` : la
|
||
/// machine de test est en UTC et « minuit » n'y tombe pas à la même heure.
|
||
private var zurich: Calendar = {
|
||
var c = Calendar(identifier: .gregorian)
|
||
c.timeZone = TimeZone(identifier: "Europe/Zurich")!
|
||
c.locale = Locale(identifier: "fr_CH")
|
||
return c
|
||
}()
|
||
|
||
private func date(_ s: String) -> Date {
|
||
let f = DateFormatter()
|
||
f.dateFormat = "yyyy-MM-dd HH:mm"
|
||
f.timeZone = TimeZone(identifier: "Europe/Zurich")!
|
||
return f.date(from: s)!
|
||
}
|
||
|
||
private func snapshot(at: String) -> CoachWidgetSnapshot {
|
||
var s = CoachWidgetSnapshot(updatedAt: date(at))
|
||
s.today = .init(sport: "running", title: "CDC J10", subtitle: "34 min · Z2", done: false)
|
||
s.tomorrow = .init(sport: "running", title: "CDC J11", subtitle: "36 min · Z2", done: false)
|
||
s.doneToday = .init(sport: "cycling", title: "VTT", subtitle: "20 km")
|
||
s.forme = .init(score: 72, label: "Prêt")
|
||
s.routine = .init(done: 8, total: 12)
|
||
s.waterMlToday = 1660
|
||
s.coffeeCountToday = 3
|
||
s.kcalToday = 1186
|
||
s.waterGoalMl = 2750
|
||
s.kcalGoal = 1950
|
||
s.zones = HeartRateZones(z2: [109, 118], fcmaxUsed: 152, clinicalTargetBpm: 112,
|
||
zonesSince: "2026-08-12", zonesFrom: "frozen")
|
||
return s
|
||
}
|
||
|
||
// MARK: - Le jour même : on ne touche à rien
|
||
|
||
func testSameDayIsUntouched() {
|
||
let s = snapshot(at: "2026-08-18 07:49")
|
||
let vu = s.asOf(date("2026-08-18 23:59"), calendar: zurich)
|
||
XCTAssertEqual(vu.waterMlToday, 1660)
|
||
XCTAssertEqual(vu.today?.title, "CDC J10")
|
||
XCTAssertEqual(vu.forme?.score, 72)
|
||
}
|
||
|
||
// MARK: - Le lendemain : c'est le bug signalé
|
||
|
||
func testCountersResetToZeroNotToNil() {
|
||
// 0 et non nil : au matin d'un jour neuf, « rien bu » est une
|
||
// information vraie, alors que nil signifierait « je ne sais pas ».
|
||
let vu = snapshot(at: "2026-08-17 22:00").asOf(date("2026-08-18 00:01"), calendar: zurich)
|
||
XCTAssertEqual(vu.waterMlToday, 0)
|
||
XCTAssertEqual(vu.coffeeCountToday, 0)
|
||
XCTAssertEqual(vu.kcalToday, 0)
|
||
}
|
||
|
||
func testYesterdaysDayScopedFieldsAreDropped() {
|
||
let vu = snapshot(at: "2026-08-17 22:00").asOf(date("2026-08-18 00:01"), calendar: zurich)
|
||
XCTAssertNil(vu.routine, "la routine se recoche chaque jour")
|
||
XCTAssertNil(vu.doneToday, "la sortie d'hier n'est pas celle d'aujourd'hui")
|
||
XCTAssertNil(vu.forme, "le score du jour n'est calculé qu'après la nuit")
|
||
}
|
||
|
||
func testTomorrowBecomesToday() {
|
||
// ⚠️ Sans ce report, la séance serait vidée et le widget annoncerait
|
||
// « JOUR OFF » chaque nuit — l'agacement d'un incident précédent,
|
||
// répété tous les jours.
|
||
let vu = snapshot(at: "2026-08-17 22:00").asOf(date("2026-08-18 00:01"), calendar: zurich)
|
||
XCTAssertEqual(vu.today?.title, "CDC J11")
|
||
XCTAssertNil(vu.tomorrow, "au-delà, on ne connaît plus le plan")
|
||
}
|
||
|
||
// MARK: - Ce qui traverse les jours doit survivre
|
||
|
||
func testGoalsAndZonesArePreserved() {
|
||
let vu = snapshot(at: "2026-08-10 12:00").asOf(date("2026-08-18 09:00"), calendar: zurich)
|
||
XCTAssertEqual(vu.waterGoalMl, 2750, "un objectif n'est pas une mesure du jour")
|
||
XCTAssertEqual(vu.kcalGoal, 1950)
|
||
XCTAssertEqual(vu.zones?.clinicalTargetBpm, 112,
|
||
"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() {
|
||
let vu = snapshot(at: "2026-08-15 08:00").asOf(date("2026-08-18 09:00"), calendar: zurich)
|
||
XCTAssertNil(vu.today, "un plan vieux de trois jours ne dit rien d'aujourd'hui")
|
||
}
|
||
|
||
// MARK: - Minuit, et le passage à l'heure d'hiver
|
||
|
||
func testNextMidnightIsLocalMidnight() {
|
||
let m = CoachWidgetSnapshot.nextMidnight(after: date("2026-08-18 22:30"), calendar: zurich)
|
||
XCTAssertEqual(zurich.component(.hour, from: m), 0)
|
||
XCTAssertEqual(zurich.component(.day, from: m), 19)
|
||
}
|
||
|
||
func testDstNightStillLandsOnMidnight() {
|
||
// ⚠️ Nuit du 25 octobre 2026 : l'heure d'hiver rallonge la journée de
|
||
// 60 minutes. Un `+86400` naïf tomberait à 23 h le 25, pas à minuit le
|
||
// 26 — le widget basculerait une heure trop tôt, la veille au soir.
|
||
let m = CoachWidgetSnapshot.nextMidnight(after: date("2026-10-25 22:00"), calendar: zurich)
|
||
XCTAssertEqual(zurich.component(.hour, from: m), 0)
|
||
XCTAssertEqual(zurich.component(.day, from: m), 26)
|
||
}
|
||
|
||
func testTheMidnightEntryAlreadyShowsTheNewDay() {
|
||
// C'est le scénario réel : la timeline est construite le soir, et
|
||
// l'entrée datée de minuit doit DÉJÀ porter le jour neuf.
|
||
let s = snapshot(at: "2026-08-18 22:30")
|
||
let minuit = CoachWidgetSnapshot.nextMidnight(after: s.updatedAt, calendar: zurich)
|
||
let vu = s.asOf(minuit, calendar: zurich)
|
||
XCTAssertEqual(vu.waterMlToday, 0)
|
||
XCTAssertEqual(vu.today?.title, "CDC J11")
|
||
}
|
||
|
||
// MARK: - Compatibilité du stockage
|
||
|
||
func testAnOlderSnapshotWithoutTomorrowStillDecodes() {
|
||
// Le snapshot déjà présent sur l'iPhone n'a pas de champ `tomorrow` :
|
||
// il doit rester lisible, sinon le widget partirait vide après la mise
|
||
// à jour au lieu de se corriger.
|
||
let json = #"{"updatedAt":776000000,"waterMlToday":1200}"#.data(using: .utf8)!
|
||
let s = try? JSONDecoder().decode(CoachWidgetSnapshot.self, from: json)
|
||
XCTAssertNotNil(s)
|
||
XCTAssertNil(s?.tomorrow)
|
||
XCTAssertEqual(s?.waterMlToday, 1200)
|
||
}
|
||
}
|