Files
coach-ios/tests-linux/Tests/CoachModelTests/DayRolloverTests.swift
Sylvain Bettinelli 1d2d7a0e18 Une toolchain Swift sur Linux, pour ne plus ecrire de dates a l'aveugle
J'ai ecrit `asOf(_:)` hier sans pouvoir le compiler. Swift 6.3 est desormais
installe sur la machine de dev (Debian 12 x86_64) et `tests-linux/` compile
et teste la logique PURE des widgets.

10 tests, dont 5 verifies rouges en neutralisant la peremption. Ils couvrent
la remise a zero, la conservation des objectifs et des zones, le report de la
seance du lendemain, et le passage a l'heure d'hiver du 25 octobre — ou un
`+86400` naif ferait basculer le widget une heure trop tot, la veille au soir.

Les sources du paquet sont des liens symboliques vers les vrais fichiers : le
test porte sur le code livre, pas sur une copie.

⚠️ Ne remplace PAS le build Xcode et ne le remplacera jamais : WidgetKit,
SwiftUI, HealthKit et WatchKit sont absents de Swift pour Linux. Ni l'app, ni
les widgets, ni les vues ne se compilent ici.
2026-08-18 07:42:59 +00:00

144 lines
6.4 KiB
Swift
Raw Permalink 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.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)
}
}