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:
27
CLAUDE.md
27
CLAUDE.md
@@ -58,6 +58,33 @@ Instructions pour Claude Code sur ce projet. Lis ce fichier en début de session
|
|||||||
- **Config Capacitor** : `capacitor.config.ts` (TS, source de vérité) → généré vers `ios/App/App/capacitor.config.json`
|
- **Config Capacitor** : `capacitor.config.ts` (TS, source de vérité) → généré vers `ios/App/App/capacitor.config.json`
|
||||||
- **Resources** : `resources/` → assets (icons, splash) générés par `@capacitor/assets`
|
- **Resources** : `resources/` → assets (icons, splash) générés par `@capacitor/assets`
|
||||||
|
|
||||||
|
## 🧪 Tests Swift sur Linux (`tests-linux/`)
|
||||||
|
|
||||||
|
Une toolchain **Swift 6.3** est installée sur la machine de dev :
|
||||||
|
`/home/coder/workspace/toolchains/bin/swift`. Lancer : `./tests-linux/run.sh`.
|
||||||
|
|
||||||
|
⚠️ **Ça ne remplace PAS le build Xcode, et ça ne le remplacera jamais.**
|
||||||
|
WidgetKit, SwiftUI, HealthKit, WatchKit et UIKit sont des frameworks Apple,
|
||||||
|
fermés et propres à leurs plateformes : absents de Swift pour Linux. Ni l'app,
|
||||||
|
ni les widgets, ni les vues ne se compilent ici. Pas de `xcodebuild`, pas de
|
||||||
|
simulateur, pas de signature, pas d'`.ipa`.
|
||||||
|
|
||||||
|
**Ce que ça couvre** : les fichiers qui n'importent que Foundation —
|
||||||
|
`CoachWidgetSnapshot.swift` (dont la péremption `asOf(_:)`) et
|
||||||
|
`CoachQuickLog.swift`. C'est-à-dire l'arithmétique de dates, là où un décalage
|
||||||
|
d'un jour ne se voit pas à la relecture.
|
||||||
|
|
||||||
|
- Les sources du paquet sont des **liens symboliques** vers les vrais fichiers :
|
||||||
|
le test porte sur le code livré, pas sur une copie qui dériverait.
|
||||||
|
- `HeartRateZonesStub.swift` est une **doublure** : le vrai type expose une
|
||||||
|
propriété `Color` et importe SwiftUI. Elle ne reproduit que les propriétés
|
||||||
|
stockées, et `asOf` ne fait que les recopier — si la péremption devait un jour
|
||||||
|
TOUCHER aux zones, ce test cesserait d'être pertinent.
|
||||||
|
|
||||||
|
⚠️ `/home/coder/workspace` est le **seul** chemin persistant du conteneur de
|
||||||
|
dev ; tout le reste (y compris les dépôts) vit sur une couche éphémère. D'où
|
||||||
|
l'emplacement de la toolchain.
|
||||||
|
|
||||||
## 🔗 Plugins Capacitor utilisés
|
## 🔗 Plugins Capacitor utilisés
|
||||||
|
|
||||||
- `@capacitor/push-notifications`
|
- `@capacitor/push-notifications`
|
||||||
|
|||||||
1
tests-linux/.gitignore
vendored
Normal file
1
tests-linux/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.build/
|
||||||
21
tests-linux/Package.swift
Normal file
21
tests-linux/Package.swift
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
// swift-tools-version: 6.0
|
||||||
|
//
|
||||||
|
// Banc d'essai Linux pour la logique PURE des widgets.
|
||||||
|
//
|
||||||
|
// ⚠️ **Ceci ne remplace PAS le build Xcode.** WidgetKit, SwiftUI, HealthKit et
|
||||||
|
// WatchKit sont des frameworks Apple, absents de Swift sur Linux : ni les
|
||||||
|
// widgets, ni l'app ne peuvent être compilés ici. Ce paquet ne construit que
|
||||||
|
// les fichiers qui n'importent que Foundation — et c'est précisément là que
|
||||||
|
// vit l'arithmétique de dates où se cachent les décalages d'un jour.
|
||||||
|
//
|
||||||
|
// Les sources sont des LIENS SYMBOLIQUES vers les vrais fichiers : le test
|
||||||
|
// porte sur le code livré, pas sur une copie qui dériverait en silence.
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "CoachModel",
|
||||||
|
targets: [
|
||||||
|
.target(name: "CoachModel"),
|
||||||
|
.testTarget(name: "CoachModelTests", dependencies: ["CoachModel"]),
|
||||||
|
]
|
||||||
|
)
|
||||||
1
tests-linux/Sources/CoachModel/CoachQuickLog.swift
Symbolic link
1
tests-linux/Sources/CoachModel/CoachQuickLog.swift
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../../ios/App/App/CoachQuickLog.swift
|
||||||
1
tests-linux/Sources/CoachModel/CoachWidgetSnapshot.swift
Symbolic link
1
tests-linux/Sources/CoachModel/CoachWidgetSnapshot.swift
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../../ios/App/App/CoachWidgetSnapshot.swift
|
||||||
21
tests-linux/Sources/CoachModel/HeartRateZonesStub.swift
Normal file
21
tests-linux/Sources/CoachModel/HeartRateZonesStub.swift
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
// DOUBLURE DE TEST — n'existe que dans le banc d'essai Linux.
|
||||||
|
//
|
||||||
|
// Le vrai `HeartRateZones` (ios/App/App/HeartRateZones.swift) expose une
|
||||||
|
// propriété `color: Color` et importe donc SwiftUI, absent sur Linux.
|
||||||
|
//
|
||||||
|
// ⚠️ La doublure ne reproduit que les propriétés STOCKÉES, à l'identique : le
|
||||||
|
// seul usage qu'en fait `CoachWidgetSnapshot` est de les porter et de les
|
||||||
|
// (dé)coder. `asOf(_:)` ne fait que recopier le champ sans le lire. Si un jour
|
||||||
|
// la péremption devait TOUCHER aux zones, ce test cesserait d'être pertinent —
|
||||||
|
// et c'est écrit ici pour qu'on s'en aperçoive.
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct HeartRateZones: Codable, Equatable, Sendable {
|
||||||
|
var z1: [Int]?
|
||||||
|
var z2: [Int]?
|
||||||
|
var z3: [Int]?
|
||||||
|
var z4: [Int]?
|
||||||
|
var z5: [Int]?
|
||||||
|
var fcmaxUsed: Int?
|
||||||
|
var clinicalTargetBpm: Int?
|
||||||
|
}
|
||||||
143
tests-linux/Tests/CoachModelTests/DayRolloverTests.swift
Normal file
143
tests-linux/Tests/CoachModelTests/DayRolloverTests.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
13
tests-linux/run.sh
Executable file
13
tests-linux/run.sh
Executable file
@@ -0,0 +1,13 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Tests Swift de la logique pure, sur Linux.
|
||||||
|
#
|
||||||
|
# ⚠️ NE REMPLACE PAS LE BUILD XCODE. WidgetKit, SwiftUI, HealthKit et WatchKit
|
||||||
|
# sont des frameworks Apple : ils n'existent pas dans Swift pour Linux. Ni les
|
||||||
|
# widgets, ni les vues, ni l'app ne se compilent ici — seuls les fichiers qui
|
||||||
|
# n'importent que Foundation.
|
||||||
|
#
|
||||||
|
# Ce que ça couvre vraiment : l'arithmétique de dates de la péremption des
|
||||||
|
# widgets, là où un décalage d'un jour passe inaperçu à la relecture.
|
||||||
|
set -euo pipefail
|
||||||
|
export PATH="/home/coder/workspace/toolchains/bin:$PATH"
|
||||||
|
exec swift test --package-path "$(dirname "$0")" "$@"
|
||||||
Reference in New Issue
Block a user