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

@@ -55,9 +55,33 @@ public class CoachWidgetBridgePlugin: CAPPlugin, CAPBridgedPlugin {
// en NSNumber via le bridge ; `.intValue` couvre Int comme Double. // en NSNumber via le bridge ; `.intValue` couvre Int comme Double.
forme = CoachWidgetSnapshot.FormeScore( forme = CoachWidgetSnapshot.FormeScore(
score: (f["score"] as? NSNumber)?.intValue, 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 /* Les champs de nutrition sont FACULTATIFS dans l'appel : le pont web
peut les omettre (objectifs indisponibles, appel partiel). Dans ce peut les omettre (objectifs indisponibles, appel partiel). Dans ce
cas on conserve ceux du snapshot existant plutôt que de les effacer. 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 (call.getValue("coffeeCountToday") as? NSNumber)?.intValue ?? previous?.coffeeCountToday
snapshot.kcalToday = number("kcalToday") ?? previous?.kcalToday snapshot.kcalToday = number("kcalToday") ?? previous?.kcalToday
snapshot.kcalGoal = number("kcalGoal") ?? previous?.kcalGoal 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 /* 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 partiel ne doit pas les effacer, sinon la montre repasserait à la FC
nue au premier rafraîchissement incomplet. Pas de garde du jour nue au premier rafraîchissement incomplet. Pas de garde du jour

View File

@@ -69,6 +69,10 @@ struct CoachWidgetSnapshot: Codable {
var kcalToday: Double? = nil var kcalToday: Double? = nil
var kcalGoal: 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. /// 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). /// 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 { struct FormeScore: Codable {
var score: Int? // 0100 var score: Int? // 0100
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 // MARK: - Péremption au changement de jour
extension CoachWidgetSnapshot { 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 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 /// **Le widget n'a aucun accès réseau.** Il rend ce que l'app lui a

View File

@@ -63,13 +63,16 @@ struct CoachQuickEntryTimeline: TimelineEntry {
let pending: Int let pending: Int
let kcal: Double? let kcal: Double?
let kcalGoal: 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 { struct CoachQuickProvider: TimelineProvider {
func placeholder(in context: Context) -> CoachQuickEntryTimeline { func placeholder(in context: Context) -> CoachQuickEntryTimeline {
CoachQuickEntryTimeline( CoachQuickEntryTimeline(
date: Date(), waterMl: 1250, waterGoal: 2750, coffeeCount: 2, pending: 0, 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. // et le badge doit continuer à le signaler.
pending: CoachQuickLog.pending().count, pending: CoachQuickLog.pending().count,
kcal: snapshot?.kcalToday, kcal: snapshot?.kcalToday,
kcalGoal: snapshot?.kcalGoal kcalGoal: snapshot?.kcalGoal,
quickDrinks: snapshot?.resolvedQuickDrinks ?? CoachWidgetSnapshot.fallbackQuickDrinks
) )
} }
} }
@@ -121,6 +125,8 @@ struct CoachQuickProvider: TimelineProvider {
struct CoachQuickWidgetView: View { struct CoachQuickWidgetView: View {
var entry: CoachQuickEntryTimeline var entry: CoachQuickEntryTimeline
private var quickDrinks: [CoachWidgetSnapshot.QuickDrink] { entry.quickDrinks }
private var litres: String { private var litres: String {
String(format: "%.1f", entry.waterMl / 1000).replacingOccurrences(of: ".", with: ",") 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) { HStack(spacing: 6) {
Button(intent: LogDrinkIntent(ref: "water", amountMl: 250)) { ForEach(Array(quickDrinks.enumerated()), id: \.offset) { _, drink in
Label("25 cl", systemImage: "drop.fill") Button(intent: LogDrinkIntent(ref: drink.ref, amountMl: drink.volumeMl)) {
} Label(drink.label,
Button(intent: LogDrinkIntent(ref: "water", amountMl: 500)) { systemImage: drink.ref == "coffee" ? "cup.and.saucer.fill" : "drop.fill")
Label("50 cl", systemImage: "drop.fill") }
}
Button(intent: LogDrinkIntent(ref: "coffee", amountMl: 100)) {
Label("Café", systemImage: "cup.and.saucer.fill")
} }
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)

View File

@@ -249,11 +249,19 @@ struct CoachTodaySessionWidget: Widget {
struct CoachFormeScoreEntryView: View { struct CoachFormeScoreEntryView: View {
var entry: CoachSnapshotEntry var entry: CoachSnapshotEntry
private func ringColor(_ score: Int) -> Color { /// Couleur de la bande servie par le serveur.
switch score { ///
case 75...: return Color(red: 0.20, green: 0.78, blue: 0.35) /// Les seuils vivaient ici (75 / 50), périmés face à ceux du serveur
case 50..<75: return Color(red: 1.00, green: 0.80, blue: 0.0) /// (65 / 55 / 40). Le score étant borné à [25, 75] par construction, le vert
default: return Color(red: 1.00, green: 0.27, blue: 0.23) /// é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().stroke(Color.secondary.opacity(0.18), lineWidth: 9)
Circle() Circle()
.trim(from: 0, to: min(max(Double(score) / 100.0, 0), 1)) .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)) .rotationEffect(.degrees(-90))
VStack(spacing: 0) { VStack(spacing: 0) {
Text("\(score)").font(.system(size: 30, weight: .bold, design: .rounded)) Text("\(score)").font(.system(size: 30, weight: .bold, design: .rounded))

View File

@@ -90,12 +90,22 @@ struct CoachWatchFormeView: View {
var entry: WatchEntry var entry: WatchEntry
private var score: Int? { entry.snapshot?.forme?.score } private var score: Int? { entry.snapshot?.forme?.score }
private var band: CoachWidgetSnapshot.FormeBand? { entry.snapshot?.forme?.resolvedBand }
private func tint(_ s: Int) -> Color { /// Couleur de la bande servie par le serveur.
switch s { ///
case 75...: return .green /// Les seuils vivaient ici (75 / 50) et ne correspondaient plus à ceux du
case 50..<75: return .yellow /// serveur (65 / 55 / 40). Le score étant borné à [25, 75], le vert était
default: return .red /// 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)") Text("\(s)")
} }
.gaugeStyle(.accessoryCircular) .gaugeStyle(.accessoryCircular)
.tint(tint(s)) .tint(tint(band))
} }
} else { } else {
Image(systemName: "bolt.heart") Image(systemName: "bolt.heart")

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)
}
}