Sylvain, après avoir reçu le widget Hydratation : « il manque juste le suivi des repas calories et lipides etc ». ⚠️ Sa demande est plus fine que ce que j'avais écarté. J'avais dit non à la SAISIE des repas sur la montre — pas de caméra, pas de scanner, pas de saisie de texte : un bouton n'aurait fait que renvoyer vers l'iPhone. Mais VOIR où l'on en est n'a aucune de ces limites. Ce qui manquait, et où ----------------------- Le snapshot portait `kcalToday`/`kcalGoal` et rien d'autre. Les macros existaient pourtant côté serveur, agrégées et servies : /api/meals -> kcal, protein_g, carbs_g, fat_g, fiber_g /api/nutrition/goals -> cibles {target, min, max} pour chacune Il manquait uniquement le transport. Trois maillons ajoutés : `widget-bridge.js` les pousse, `CoachWidgetSnapshot` les porte, `CoachWatchNutritionWidget` les affiche. ⛔ Rien n'est calculé côté natif. Les totaux viennent du journal, les cibles de `nutrition_goals.py`. Le seul calcul est un reste (cible − consommé), la même soustraction que /meals affiche déjà. Le piège évité, parce qu'il est documenté dans ce fichier --------------------------------------------------------- `asOf(_:)` remet les totaux du jour à zéro quand le snapshot date d'hier. Les macros y sont ajoutées : les oublier aurait affiché les grammes de la veille sous la date du jour — le défaut même que cette fonction existe pour empêcher, et qui avait survécu un temps sur les totaux de boisson. ⚠️ Les CIBLES ne se périment pas, elles : une cible n'est pas une mesure. Sans ça, la jauge disparaîtrait chaque nuit jusqu'à la prochaine ouverture de l'app. Deux tests Linux le verrouillent (`testMacrosResetLikeEnergy`, `testMacroGoalsSurviveTheNight`) — 93 au vert. ⚠️ Une cible de type « min » (les protéines) se lit comme un plancher atteint, pas comme un plafond à ne pas dépasser. `widget-bridge.js` ne pousse que `target` ; le widget n'en sait pas plus et ne prétend pas le contraire. Le widget compile-t-il ? Inconnu, comme les précédents : WidgetKit n'existe pas sur Linux. Vérifié en revanche que chaque champ appelé existe, et qu'aucun symbole n'est déclaré deux fois. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
209 lines
9.8 KiB
Swift
209 lines
9.8 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.proteinToday = 74
|
||
s.fatToday = 41
|
||
s.carbsToday = 158
|
||
s.waterGoalMl = 2750
|
||
s.kcalGoal = 1950
|
||
s.proteinGoal = 118
|
||
s.fatGoal = 59
|
||
s.carbsGoal = 225
|
||
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 testMacrosResetLikeEnergy() {
|
||
// ⚠️ Ajoutées le 25/09/2026 pour le suivi au poignet. Les oublier dans
|
||
// `asOf` afficherait les grammes d'hier sous la date du jour — le
|
||
// défaut même que cette fonction existe pour empêcher, et qui avait
|
||
// survécu un temps sur les totaux de boisson.
|
||
let vu = snapshot(at: "2026-08-17 22:00").asOf(date("2026-08-18 00:01"), calendar: zurich)
|
||
XCTAssertEqual(vu.proteinToday, 0)
|
||
XCTAssertEqual(vu.fatToday, 0)
|
||
XCTAssertEqual(vu.carbsToday, 0)
|
||
}
|
||
|
||
func testMacroGoalsSurviveTheNight() {
|
||
// Une CIBLE n'est pas une mesure : elle ne se périme pas à minuit.
|
||
// Sans elle, la jauge disparaîtrait chaque nuit jusqu'à la prochaine
|
||
// ouverture de l'app.
|
||
let vu = snapshot(at: "2026-08-17 22:00").asOf(date("2026-08-18 00:01"), calendar: zurich)
|
||
XCTAssertEqual(vu.proteinGoal, 118)
|
||
XCTAssertEqual(vu.fatGoal, 59)
|
||
XCTAssertEqual(vu.carbsGoal, 225)
|
||
XCTAssertEqual(vu.kcalGoal, 1950)
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|