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.
This commit is contained in:
Sylvain Bettinelli
2026-08-18 07:42:59 +00:00
parent b8eb1e1b1e
commit 1d2d7a0e18
8 changed files with 228 additions and 0 deletions

View File

@@ -0,0 +1,143 @@
// 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)
}
}