Les widgets reçoivent la bande et les préréglages au lieu de les inventer

La couleur du score de forme était décidée ici, avec un barème périmé
(vert >= 75, jaune >= 50). Le serveur avait recalé le sien sur la
distribution réelle (65/55/40) parce que le score est borné à [25, 75] :
une bande haute à 75 est inatteignable. Mesuré sur 115 jours de
production, le vert n'est jamais sorti et le rouge couvrait 68 jours.
La correction n'avait pas atteint le binaire.

`FormeScore` porte désormais la bande servie par le serveur, et
`resolvedBand` ne sert que de repli — aligné sur les seuils serveur, pas
sur les anciens.

Le widget de saisie rapide enregistrait un café à 100 ml, volume qui ne
correspond à aucun préréglage : le serveur avait séparé l'expresso (60)
du mug (250) parce qu'un volume unique faussait le journal, et ces
préréglages sont personnalisables. Les boutons viennent maintenant du
snapshot ; `fallbackQuickDrinks` reprend les préréglages par défaut du
serveur.

15 tests ajoutés au banc d'essai Linux, qui compile réellement ces deux
fichiers : 25 tests verts. Le reste (SwiftUI, WidgetKit) n'est pas
compilable ici et n'a été que relu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Sylvain Bettinelli
2026-08-19 11:42:19 +00:00
parent a965c1ad2b
commit 4cff1d4603
7 changed files with 304 additions and 24 deletions

View File

@@ -0,0 +1,77 @@
// La bande du score de forme vient du serveur, plus de seuils recopiés.
//
// **Ce qui est en jeu.** La complication watchOS et les widgets d'accueil
// coloraient le score avec leurs propres seuils : vert 75, jaune 50-74, rouge
// en dessous. Le serveur avait pourtant recalé les siens (65/55/40) en
// documentant pourquoi : le score est borné à [25, 75] par construction, donc
// une bande haute à 75 est inatteignable.
//
// Mesuré sur 115 jours de production : le vert n'est jamais sorti (0 jour), et
// le rouge couvrait 68 jours sur 115. La correction serveur n'avait pas atteint
// le binaire, qui ne se déploie qu'au build suivant.
//
// Le natif ne décide donc plus : il reçoit `band` et se contente de la traduire
// en couleur. Le repli, pour un snapshot écrit par une version antérieure,
// reprend les seuils du serveur jamais les anciens.
import XCTest
@testable import CoachModel
final class FormeBandTests: XCTestCase {
private func snapshot(score: Int?, band: String?) -> CoachWidgetSnapshot {
var s = CoachWidgetSnapshot(updatedAt: Date(timeIntervalSince1970: 1_755_000_000))
s.forme = CoachWidgetSnapshot.FormeScore(score: score, label: nil, band: band)
return s
}
func testTheBandFromTheServerIsUsedAsIs() {
let s = snapshot(score: 60, band: "good")
XCTAssertEqual(s.forme?.resolvedBand, .good)
}
func testTheCaseThatWasWrong() {
// 60 : « bonne récupération » côté serveur, rouge sur la montre.
let s = snapshot(score: 60, band: "good")
XCTAssertEqual(s.forme?.resolvedBand, .good)
XCTAssertNotEqual(s.forme?.resolvedBand, .low)
}
func testAnUnknownBandFallsBackOnTheScore() {
XCTAssertEqual(snapshot(score: 60, band: "farfelu").forme?.resolvedBand, .good)
}
func testWithoutABandTheServerThresholdsApply() {
// Repli aligné sur RECOVERY_BANDS : 65 / 55 / 40.
XCTAssertEqual(snapshot(score: 69, band: nil).forme?.resolvedBand, .great)
XCTAssertEqual(snapshot(score: 65, band: nil).forme?.resolvedBand, .great)
XCTAssertEqual(snapshot(score: 64, band: nil).forme?.resolvedBand, .good)
XCTAssertEqual(snapshot(score: 55, band: nil).forme?.resolvedBand, .good)
XCTAssertEqual(snapshot(score: 54, band: nil).forme?.resolvedBand, .medium)
XCTAssertEqual(snapshot(score: 40, band: nil).forme?.resolvedBand, .medium)
XCTAssertEqual(snapshot(score: 39, band: nil).forme?.resolvedBand, .low)
}
func testTheObservedRangeIsCoveredByMoreThanOneBand() {
// Sur 115 jours, les scores vont de 30 à 69. Un barème qui rendrait la
// même bande sur toute cette plage ne dirait plus rien.
let bands = Set([30, 37, 45, 60, 69].map {
snapshot(score: $0, band: nil).forme?.resolvedBand
})
XCTAssertGreaterThan(bands.count, 1)
XCTAssertTrue(bands.contains(.great), "le haut de la plage doit être atteignable")
}
func testNoScoreNoBand() {
XCTAssertNil(snapshot(score: nil, band: nil).forme?.resolvedBand)
}
func testAnOlderSnapshotStillDecodes() throws {
// Un snapshot écrit avant l'ajout du champ `band` doit rester lisible.
let json = """
{"updatedAt": 1755000000, "forme": {"score": 58, "label": "Modéré"}}
""".data(using: .utf8)!
let decoded = try JSONDecoder().decode(CoachWidgetSnapshot.self, from: json)
XCTAssertEqual(decoded.forme?.score, 58)
XCTAssertEqual(decoded.forme?.resolvedBand, .good)
}
}

View File

@@ -0,0 +1,83 @@
// Les boutons de saisie rapide viennent des préréglages de l'utilisateur.
//
// **Ce qui est en jeu.** Le widget enregistrait un café à 100 ml, en dur. Le
// serveur avait pourtant tranché la question, et documenté pourquoi : « 60 ml,
// c'est un expresso. L'ancien bouton "Café" imposait ce volume à tous les
// cafés, y compris un mug de 250 ml : le journal était faux. » Il propose
// désormais expresso 60 et mug 250, et ces préréglages sont **personnalisables**
// (`GET/PUT /api/drinks/presets`). Le widget ignorait tout cela 100 ml ne
// correspond à aucun préréglage.
//
// Les volumes voyagent donc dans le snapshot. Le repli, pour un snapshot écrit
// par une version antérieure, reprend les valeurs du serveur jamais les 100 ml.
import XCTest
@testable import CoachModel
final class QuickDrinkTests: XCTestCase {
private func snapshot(_ drinks: [CoachWidgetSnapshot.QuickDrink]?) -> CoachWidgetSnapshot {
var s = CoachWidgetSnapshot(updatedAt: Date(timeIntervalSince1970: 1_755_000_000))
s.quickDrinks = drinks
return s
}
func testTheUsersPresetsAreUsedAsIs() {
let presets = [
CoachWidgetSnapshot.QuickDrink(ref: "water", label: "Verre", volumeMl: 200),
CoachWidgetSnapshot.QuickDrink(ref: "coffee", label: "Expresso", volumeMl: 60),
]
XCTAssertEqual(snapshot(presets).resolvedQuickDrinks, presets)
}
func testWithoutPresetsTheFallbackMatchesTheServer() {
let fallback = snapshot(nil).resolvedQuickDrinks
XCTAssertEqual(fallback.count, 3)
XCTAssertEqual(fallback.map(\.volumeMl), [200, 500, 60])
XCTAssertEqual(fallback.map(\.ref), ["water", "water", "coffee"])
}
func testTheCoffeeIsNeverOneHundredMillilitres() {
// La valeur qui ne correspondait à aucun préréglage.
for drink in snapshot(nil).resolvedQuickDrinks where drink.ref == "coffee" {
XCTAssertNotEqual(drink.volumeMl, 100)
}
}
func testAnEmptyListFallsBackRatherThanShowNothing() {
XCTAssertEqual(snapshot([]).resolvedQuickDrinks.count, 3)
}
func testAtMostThreeButtonsFit() {
let many = (1...8).map { i in
CoachWidgetSnapshot.QuickDrink(ref: "water", label: "P\(i)",
volumeMl: Double(i) * 100)
}
XCTAssertEqual(snapshot(many).resolvedQuickDrinks.count, 3)
}
func testAnAbsurdVolumeIsRefused() {
// Un préréglage corrompu ne doit pas produire un bouton qui enregistre
// n'importe quoi : on retombe sur le repli.
let bad = [CoachWidgetSnapshot.QuickDrink(ref: "water", label: "X", volumeMl: 0)]
XCTAssertEqual(snapshot(bad).resolvedQuickDrinks.map(\.volumeMl), [200, 500, 60])
}
func testAnOlderSnapshotStillDecodes() throws {
let json = """
{"updatedAt": 1755000000, "waterMlToday": 500}
""".data(using: .utf8)!
let decoded = try JSONDecoder().decode(CoachWidgetSnapshot.self, from: json)
XCTAssertEqual(decoded.waterMlToday, 500)
XCTAssertEqual(decoded.resolvedQuickDrinks.count, 3)
}
func testPresetsSurviveTheDayRollover() throws {
// Les préréglages ne sont pas une donnée du jour : ils ne se périment pas.
var s = snapshot([CoachWidgetSnapshot.QuickDrink(ref: "coffee", label: "Mug", volumeMl: 250)])
s.waterMlToday = 800
let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: s.updatedAt)!
let aged = s.asOf(tomorrow)
XCTAssertEqual(aged.waterMlToday, 0, "l'eau du jour se remet à zéro")
XCTAssertEqual(aged.resolvedQuickDrinks.first?.volumeMl, 250)
}
}