From 1d2d7a0e18a347f0aa624bf7fa7bdef0b9bcdbb1 Mon Sep 17 00:00:00 2001 From: Sylvain Bettinelli Date: Tue, 18 Aug 2026 07:42:59 +0000 Subject: [PATCH] Une toolchain Swift sur Linux, pour ne plus ecrire de dates a l'aveugle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CLAUDE.md | 27 ++++ tests-linux/.gitignore | 1 + tests-linux/Package.swift | 21 +++ .../Sources/CoachModel/CoachQuickLog.swift | 1 + .../CoachModel/CoachWidgetSnapshot.swift | 1 + .../CoachModel/HeartRateZonesStub.swift | 21 +++ .../CoachModelTests/DayRolloverTests.swift | 143 ++++++++++++++++++ tests-linux/run.sh | 13 ++ 8 files changed, 228 insertions(+) create mode 100644 tests-linux/.gitignore create mode 100644 tests-linux/Package.swift create mode 120000 tests-linux/Sources/CoachModel/CoachQuickLog.swift create mode 120000 tests-linux/Sources/CoachModel/CoachWidgetSnapshot.swift create mode 100644 tests-linux/Sources/CoachModel/HeartRateZonesStub.swift create mode 100644 tests-linux/Tests/CoachModelTests/DayRolloverTests.swift create mode 100755 tests-linux/run.sh diff --git a/CLAUDE.md b/CLAUDE.md index 59b9cba..cbc262f 100644 --- a/CLAUDE.md +++ b/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` - **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 - `@capacitor/push-notifications` diff --git a/tests-linux/.gitignore b/tests-linux/.gitignore new file mode 100644 index 0000000..30bcfa4 --- /dev/null +++ b/tests-linux/.gitignore @@ -0,0 +1 @@ +.build/ diff --git a/tests-linux/Package.swift b/tests-linux/Package.swift new file mode 100644 index 0000000..552aa24 --- /dev/null +++ b/tests-linux/Package.swift @@ -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"]), + ] +) diff --git a/tests-linux/Sources/CoachModel/CoachQuickLog.swift b/tests-linux/Sources/CoachModel/CoachQuickLog.swift new file mode 120000 index 0000000..e15bef9 --- /dev/null +++ b/tests-linux/Sources/CoachModel/CoachQuickLog.swift @@ -0,0 +1 @@ +../../../ios/App/App/CoachQuickLog.swift \ No newline at end of file diff --git a/tests-linux/Sources/CoachModel/CoachWidgetSnapshot.swift b/tests-linux/Sources/CoachModel/CoachWidgetSnapshot.swift new file mode 120000 index 0000000..6ee1a1a --- /dev/null +++ b/tests-linux/Sources/CoachModel/CoachWidgetSnapshot.swift @@ -0,0 +1 @@ +../../../ios/App/App/CoachWidgetSnapshot.swift \ No newline at end of file diff --git a/tests-linux/Sources/CoachModel/HeartRateZonesStub.swift b/tests-linux/Sources/CoachModel/HeartRateZonesStub.swift new file mode 100644 index 0000000..502f484 --- /dev/null +++ b/tests-linux/Sources/CoachModel/HeartRateZonesStub.swift @@ -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? +} diff --git a/tests-linux/Tests/CoachModelTests/DayRolloverTests.swift b/tests-linux/Tests/CoachModelTests/DayRolloverTests.swift new file mode 100644 index 0000000..03a7393 --- /dev/null +++ b/tests-linux/Tests/CoachModelTests/DayRolloverTests.swift @@ -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) + } +} diff --git a/tests-linux/run.sh b/tests-linux/run.sh new file mode 100755 index 0000000..5a5c8b0 --- /dev/null +++ b/tests-linux/run.sh @@ -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")" "$@"