Files
coach-ios/tests-linux/Tests/CoachModelTests/DayRolloverTests.swift
Sylvain Bettinelli 6aa7265147 Les macros arrivent au poignet — affichage, pas saisie
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>
2026-09-25 07:46:32 +00:00

209 lines
9.8 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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)
}
}