// 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) 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") } // 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) } }