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:
@@ -55,9 +55,33 @@ public class CoachWidgetBridgePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
// en NSNumber via le bridge ; `.intValue` couvre Int comme Double.
|
||||
forme = CoachWidgetSnapshot.FormeScore(
|
||||
score: (f["score"] as? NSNumber)?.intValue,
|
||||
label: f["label"] as? String
|
||||
label: f["label"] as? String,
|
||||
// Bande servie par le serveur (`app.recovery_band`). Les widgets
|
||||
// la coloraient avec des seuils à eux, périmés : le vert était
|
||||
// devenu inatteignable. Absente ⇒ `resolvedBand` retombe sur les
|
||||
// seuils du serveur, jamais sur les anciens.
|
||||
band: f["band"] as? String
|
||||
)
|
||||
}
|
||||
|
||||
/* Boutons de saisie rapide, depuis les préréglages de l'utilisateur.
|
||||
Le widget les avait en dur, dont un café à 100 ml qui ne correspondait
|
||||
à aucun préréglage. Liste absente ou vide ⇒ repli documenté dans
|
||||
`CoachWidgetSnapshot.fallbackQuickDrinks`. */
|
||||
var quickDrinks: [CoachWidgetSnapshot.QuickDrink]?
|
||||
if let raw = call.getArray("quickDrinks") as? [[String: Any]] {
|
||||
let parsed = raw.compactMap { item -> CoachWidgetSnapshot.QuickDrink? in
|
||||
guard let ref = item["ref"] as? String,
|
||||
let volume = (item["volumeMl"] as? NSNumber)?.doubleValue,
|
||||
volume > 0 else { return nil }
|
||||
return CoachWidgetSnapshot.QuickDrink(
|
||||
ref: ref,
|
||||
label: item["label"] as? String ?? ref,
|
||||
volumeMl: volume
|
||||
)
|
||||
}
|
||||
if !parsed.isEmpty { quickDrinks = parsed }
|
||||
}
|
||||
/* Les champs de nutrition sont FACULTATIFS dans l'appel : le pont web
|
||||
peut les omettre (objectifs indisponibles, appel partiel). Dans ce
|
||||
cas on conserve ceux du snapshot existant plutôt que de les effacer.
|
||||
@@ -120,6 +144,9 @@ public class CoachWidgetBridgePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
(call.getValue("coffeeCountToday") as? NSNumber)?.intValue ?? previous?.coffeeCountToday
|
||||
snapshot.kcalToday = number("kcalToday") ?? previous?.kcalToday
|
||||
snapshot.kcalGoal = number("kcalGoal") ?? previous?.kcalGoal
|
||||
// Préréglages : même règle de report, et pas de garde du jour — des
|
||||
// volumes de verre ne périment pas à minuit.
|
||||
snapshot.quickDrinks = quickDrinks ?? previous?.quickDrinks
|
||||
/* Zones cardiaques : même règle de report que la nutrition — un appel
|
||||
partiel ne doit pas les effacer, sinon la montre repasserait à la FC
|
||||
nue au premier rafraîchissement incomplet. Pas de garde du jour
|
||||
|
||||
@@ -69,6 +69,10 @@ struct CoachWidgetSnapshot: Codable {
|
||||
var kcalToday: Double? = nil
|
||||
var kcalGoal: Double? = nil
|
||||
|
||||
/// Boutons de saisie rapide, poussés depuis les préréglages de boisson.
|
||||
/// `nil` ⇒ repli aligné sur les préréglages par défaut du serveur.
|
||||
var quickDrinks: [QuickDrink]? = nil
|
||||
|
||||
|
||||
/// Activité RÉELLEMENT réalisée aujourd'hui, indépendante du plan.
|
||||
///
|
||||
@@ -132,15 +136,77 @@ struct CoachWidgetSnapshot: Codable {
|
||||
}
|
||||
|
||||
/// Score de forme / récupération (nil = pas encore de score).
|
||||
/// Un bouton de saisie rapide du widget, tel que l'utilisateur l'a réglé.
|
||||
///
|
||||
/// ⚠️ Les volumes étaient en dur dans le widget, dont un café à 100 ml qui
|
||||
/// ne correspondait à aucun préréglage : le serveur avait tranché que 60 ml
|
||||
/// est un expresso et 250 ml un mug, précisément parce qu'un volume unique
|
||||
/// pour tous les cafés faussait le journal. Ces préréglages sont en outre
|
||||
/// personnalisables (`/api/drinks/presets`) — les figer ici les ignorait.
|
||||
struct QuickDrink: Codable, Equatable, Sendable {
|
||||
var ref: String // "water" | "coffee" | … (drink_type de l'API)
|
||||
var label: String
|
||||
var volumeMl: Double
|
||||
}
|
||||
|
||||
/// Bande de lecture du score de forme, telle que le serveur la nomme
|
||||
/// (`app.RECOVERY_BANDS`). Le natif la traduit en couleur ; il ne la décide
|
||||
/// plus.
|
||||
enum FormeBand: String, Codable, Sendable {
|
||||
case great, good, medium, low
|
||||
}
|
||||
|
||||
struct FormeScore: Codable {
|
||||
var score: Int? // 0–100
|
||||
var label: String? // "Prêt", "Récup", "Fatigue"…
|
||||
var label: String? // "Excellente", "Bonne", "Correcte", "Basse"
|
||||
/// Bande servie par le serveur. `nil` pour un snapshot écrit par une
|
||||
/// version antérieure : on retombe alors sur `resolvedBand`.
|
||||
var band: String? = nil
|
||||
|
||||
/// Seuils de repli — **ceux du serveur**, pas les anciens.
|
||||
///
|
||||
/// ⚠️ La complication et les widgets coloraient le score avec un barème
|
||||
/// à eux (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] par construction : 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 suivi jusqu'ici,
|
||||
/// le binaire ne se déployant qu'au build suivant.
|
||||
static let fallbackThresholds: [(min: Int, band: FormeBand)] = [
|
||||
(65, .great), (55, .good), (40, .medium),
|
||||
]
|
||||
|
||||
/// Bande à afficher : celle du serveur, sinon déduite du score.
|
||||
var resolvedBand: FormeBand? {
|
||||
if let raw = band, let known = FormeBand(rawValue: raw) { return known }
|
||||
guard let score = score else { return nil }
|
||||
for step in Self.fallbackThresholds where score >= step.min { return step.band }
|
||||
return .low
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Péremption au changement de jour
|
||||
|
||||
extension CoachWidgetSnapshot {
|
||||
/// Repli des boutons de saisie rapide — **les préréglages par défaut du
|
||||
/// serveur** (`drink_presets.DEFAULT_PRESETS`), jamais des valeurs inventées.
|
||||
/// Trois boutons : c'est ce que la largeur du widget permet.
|
||||
static let fallbackQuickDrinks: [QuickDrink] = [
|
||||
QuickDrink(ref: "water", label: "Verre", volumeMl: 200),
|
||||
QuickDrink(ref: "water", label: "Bouteille", volumeMl: 500),
|
||||
QuickDrink(ref: "coffee", label: "Expresso", volumeMl: 60),
|
||||
]
|
||||
|
||||
/// Boutons à afficher : ceux de l'utilisateur, sinon le repli.
|
||||
///
|
||||
/// Un préréglage sans volume plausible est écarté : un bouton qui
|
||||
/// enregistrerait 0 ml serait pire que pas de bouton.
|
||||
var resolvedQuickDrinks: [QuickDrink] {
|
||||
let usable = (quickDrinks ?? []).filter { $0.volumeMl > 0 && $0.volumeMl <= 2000 }
|
||||
return usable.isEmpty ? Self.fallbackQuickDrinks : Array(usable.prefix(3))
|
||||
}
|
||||
|
||||
/// Le snapshot tel qu'il doit s'afficher le jour `day`.
|
||||
///
|
||||
/// ⚠️ **Le widget n'a aucun accès réseau.** Il rend ce que l'app lui a
|
||||
|
||||
@@ -63,13 +63,16 @@ struct CoachQuickEntryTimeline: TimelineEntry {
|
||||
let pending: Int
|
||||
let kcal: Double?
|
||||
let kcalGoal: Double?
|
||||
/// Boutons de saisie rapide, résolus depuis les préréglages de l'utilisateur.
|
||||
var quickDrinks: [CoachWidgetSnapshot.QuickDrink] = CoachWidgetSnapshot.fallbackQuickDrinks
|
||||
}
|
||||
|
||||
struct CoachQuickProvider: TimelineProvider {
|
||||
func placeholder(in context: Context) -> CoachQuickEntryTimeline {
|
||||
CoachQuickEntryTimeline(
|
||||
date: Date(), waterMl: 1250, waterGoal: 2750, coffeeCount: 2, pending: 0,
|
||||
kcal: 1420, kcalGoal: 1950
|
||||
kcal: 1420, kcalGoal: 1950,
|
||||
quickDrinks: CoachWidgetSnapshot.fallbackQuickDrinks
|
||||
)
|
||||
}
|
||||
|
||||
@@ -110,7 +113,8 @@ struct CoachQuickProvider: TimelineProvider {
|
||||
// et le badge doit continuer à le signaler.
|
||||
pending: CoachQuickLog.pending().count,
|
||||
kcal: snapshot?.kcalToday,
|
||||
kcalGoal: snapshot?.kcalGoal
|
||||
kcalGoal: snapshot?.kcalGoal,
|
||||
quickDrinks: snapshot?.resolvedQuickDrinks ?? CoachWidgetSnapshot.fallbackQuickDrinks
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -121,6 +125,8 @@ struct CoachQuickProvider: TimelineProvider {
|
||||
struct CoachQuickWidgetView: View {
|
||||
var entry: CoachQuickEntryTimeline
|
||||
|
||||
private var quickDrinks: [CoachWidgetSnapshot.QuickDrink] { entry.quickDrinks }
|
||||
|
||||
private var litres: String {
|
||||
String(format: "%.1f", entry.waterMl / 1000).replacingOccurrences(of: ".", with: ",")
|
||||
}
|
||||
@@ -193,15 +199,18 @@ struct CoachQuickWidgetView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// ⚠️ Volumes pilotés par les préréglages de l'utilisateur
|
||||
// (`/api/drinks/presets`), plus écrits ici. Le café valait 100 ml en
|
||||
// dur, un volume qui ne correspond à aucun préréglage : le serveur
|
||||
// avait justement séparé l'expresso (60) du mug (250) parce qu'un
|
||||
// volume unique faussait le journal. Repli documenté dans
|
||||
// `CoachWidgetSnapshot.fallbackQuickDrinks`.
|
||||
HStack(spacing: 6) {
|
||||
Button(intent: LogDrinkIntent(ref: "water", amountMl: 250)) {
|
||||
Label("25 cl", systemImage: "drop.fill")
|
||||
}
|
||||
Button(intent: LogDrinkIntent(ref: "water", amountMl: 500)) {
|
||||
Label("50 cl", systemImage: "drop.fill")
|
||||
}
|
||||
Button(intent: LogDrinkIntent(ref: "coffee", amountMl: 100)) {
|
||||
Label("Café", systemImage: "cup.and.saucer.fill")
|
||||
ForEach(Array(quickDrinks.enumerated()), id: \.offset) { _, drink in
|
||||
Button(intent: LogDrinkIntent(ref: drink.ref, amountMl: drink.volumeMl)) {
|
||||
Label(drink.label,
|
||||
systemImage: drink.ref == "coffee" ? "cup.and.saucer.fill" : "drop.fill")
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
@@ -249,11 +249,19 @@ struct CoachTodaySessionWidget: Widget {
|
||||
struct CoachFormeScoreEntryView: View {
|
||||
var entry: CoachSnapshotEntry
|
||||
|
||||
private func ringColor(_ score: Int) -> Color {
|
||||
switch score {
|
||||
case 75...: return Color(red: 0.20, green: 0.78, blue: 0.35)
|
||||
case 50..<75: return Color(red: 1.00, green: 0.80, blue: 0.0)
|
||||
default: return Color(red: 1.00, green: 0.27, blue: 0.23)
|
||||
/// Couleur de la bande servie par le serveur.
|
||||
///
|
||||
/// ⚠️ Les seuils vivaient ici (75 / 50), périmés face à ceux du serveur
|
||||
/// (65 / 55 / 40). Le score étant borné à [25, 75] par construction, le vert
|
||||
/// était inatteignable — 0 jour sur 115 en production. Voir
|
||||
/// `CoachWidgetSnapshot.FormeScore.resolvedBand`, qui porte le repli.
|
||||
private func ringColor(_ band: CoachWidgetSnapshot.FormeBand?) -> Color {
|
||||
switch band {
|
||||
case .great: return Color(red: 0.20, green: 0.78, blue: 0.35)
|
||||
case .good: return Color(red: 0.30, green: 0.85, blue: 0.60)
|
||||
case .medium: return Color(red: 1.00, green: 0.80, blue: 0.0)
|
||||
case .low: return Color(red: 1.00, green: 0.27, blue: 0.23)
|
||||
case nil: return Color.gray
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,7 +278,7 @@ struct CoachFormeScoreEntryView: View {
|
||||
Circle().stroke(Color.secondary.opacity(0.18), lineWidth: 9)
|
||||
Circle()
|
||||
.trim(from: 0, to: min(max(Double(score) / 100.0, 0), 1))
|
||||
.stroke(ringColor(score), style: StrokeStyle(lineWidth: 9, lineCap: .round))
|
||||
.stroke(ringColor(forme?.resolvedBand), style: StrokeStyle(lineWidth: 9, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
VStack(spacing: 0) {
|
||||
Text("\(score)").font(.system(size: 30, weight: .bold, design: .rounded))
|
||||
|
||||
@@ -90,12 +90,22 @@ struct CoachWatchFormeView: View {
|
||||
var entry: WatchEntry
|
||||
|
||||
private var score: Int? { entry.snapshot?.forme?.score }
|
||||
private var band: CoachWidgetSnapshot.FormeBand? { entry.snapshot?.forme?.resolvedBand }
|
||||
|
||||
private func tint(_ s: Int) -> Color {
|
||||
switch s {
|
||||
case 75...: return .green
|
||||
case 50..<75: return .yellow
|
||||
default: return .red
|
||||
/// Couleur de la bande servie par le serveur.
|
||||
///
|
||||
/// ⚠️ Les seuils vivaient ici (75 / 50) et ne correspondaient plus à ceux du
|
||||
/// serveur (65 / 55 / 40). Le score étant borné à [25, 75], le vert était
|
||||
/// inatteignable : sur 115 jours de production, il n'est jamais sorti, et le
|
||||
/// rouge couvrait 68 jours. La bande arrive maintenant avec le snapshot ;
|
||||
/// le repli vit dans `FormeScore.resolvedBand`, aligné sur le serveur.
|
||||
private func tint(_ band: CoachWidgetSnapshot.FormeBand?) -> Color {
|
||||
switch band {
|
||||
case .great: return .green
|
||||
case .good: return .mint
|
||||
case .medium: return .yellow
|
||||
case .low: return .red
|
||||
case nil: return .gray
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,7 +124,7 @@ struct CoachWatchFormeView: View {
|
||||
Text("\(s)")
|
||||
}
|
||||
.gaugeStyle(.accessoryCircular)
|
||||
.tint(tint(s))
|
||||
.tint(tint(band))
|
||||
}
|
||||
} else {
|
||||
Image(systemName: "bolt.heart")
|
||||
|
||||
77
tests-linux/Tests/CoachModelTests/FormeBandTests.swift
Normal file
77
tests-linux/Tests/CoachModelTests/FormeBandTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
83
tests-linux/Tests/CoachModelTests/QuickDrinkTests.swift
Normal file
83
tests-linux/Tests/CoachModelTests/QuickDrinkTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user