etm-powersync-app/test/load_telemetry_test.dart
Patrick Schurig a1455290c9 feat(3g): BATTERY_RESERVE branché, funding lu, et l'état d'une borne passe devant
Deux constats du brief moteur, qui touchent le même fichier de lecture.

1. kCodesReserveBatterie était vide, et les clés cherchées n'ont JAMAIS existé côté
   plugin : batteryLevel/soc et threshold/batteryLevelConsideration contre socPercent,
   reservePercent, withheldW. Le motif sortait donc en repli — c'est-à-dire en anglais
   brut — sur le seul cas que le client ne peut déduire d'aucune mesure : « il y a du
   surplus, une règle l'interdit ». Vérifié contre decisionreason.h.

   Les trois paramètres sont des entiers DÉJÀ en pourcentage : reservePercent vaut 40, pas
   0,4 — à ne pas confondre avec batteryLevelConsideration, qui est la fraction du réglage.
   Et withheldW mène la phrase, comme côté plugin : le moteur en fait la condition
   d'émission du motif, parce qu'un motif à 0 W dirait « une règle vous bloque » là où la
   vérité est « il n'y a pas de surplus ».

   Le motif n'a PAS pu être observé en vrai sur .75 : la bascule du seuil à 0,9 a produit
   surplusW à −3 551 W, donc rien à confisquer, donc garde du plugin active et motif tu —
   ce qui est le comportement juste. Il faut du surplus réel sous le seuil. Seuil remis
   à 0,4.

2. funding (« surplus » / « grid ») est lu, et avec lui l'identité que le plugin déclare :
   somme des allocatedW financés au surplus == budget.allocatedW. Sommer sans filtrer
   compte une borne servie au réseau et creuse un écart qu'on ne peut pas interpréter.
   Absent = surplus, pas zéro : c'est le seul financement qui existait avant ce champ.

3. Les bornes sont entrées dans loads[] (+etm22), et adjustEvChargers() re-décide après le
   waterfall : pour une borne, allocatedW et son motif sont la DÉCISION DE L'ARBITRE, pas
   l'état de la borne. L'écran affiche donc d'abord ce que le matériel fait
   (mechanism.chargingEnabled / currentA, qui viennent du Thing), ensuite ce que le moteur
   a voulu, étiqueté comme tel. L'écart mesuré/commandé se tait sur une borne — « sous la
   consigne » y supposerait une consigne. La réserve ne touche PAS l'ECS ni la PAC, où
   l'allocation publiée est bien la commande : elle est portée par allocationIsCommand, et
   tombe avec le lot plugin 3g-2.

EV_SURPLUS, EV_ECO_MIN et EV_IDLE ne sont plus émis. Les branches restent : les retirer
rendrait illisible un journal antérieur, pas l'app plus juste.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
2026-08-27 07:38:48 +02:00

518 lines
22 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:convert';
import 'dart:io';
import 'package:etm_powersync_app/l10n/app_localizations.dart';
import 'package:etm_powersync_app/models/load_telemetry.dart';
import 'package:etm_powersync_app/services/telemetry_text.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
Map<String, dynamic> _fixture(String name, String key) {
final raw = File('test/fixtures/$name').readAsStringSync();
return Map<String, dynamic>.from(
(jsonDecode(raw) as Map<String, dynamic>)[key] as Map);
}
void main() {
late L10n t;
setUpAll(() async {
t = await L10n.delegate.load(const Locale('fr'));
});
// ───────────────────────────────────────────────────────────────────────────
group('§7-5 — lecture de la charge utile réelle de .75', () {
test('le dump du banc se lit entièrement, budget et mécanismes compris', () {
final tel =
LoadTelemetry.fromJson(_fixture('loadtelemetry_hems75_v3.json', 'telemetry'));
expect(tel.loads.map((l) => l.loadId), ['chauffe-eau', 'pac-terrain']);
expect(tel.degradedMode, isFalse);
expect(tel.hasNeverRun, isFalse);
expect(tel.timestamp, isNotNull);
final ecs = tel.entryFor('chauffe-eau')!;
expect(ecs.available, isTrue);
expect(ecs.faultCode, isNull);
expect(ecs.mechanism!.kind, 'relay');
expect(ecs.mechanism!.stageW, 3500);
expect(ecs.decision.code, 'SURPLUS_SETPOINT');
final pac = tel.entryFor('pac-terrain')!;
expect(pac.mechanism!.kind, 'sgReady');
expect(pac.mechanism!.state, 4);
expect(pac.decision.code, 'SG_FORCED');
// Aucun verrou sur ce cycle-là : `lock` est optionnel, et son absence se lit
// « aucun verrou ne mord », jamais « verrou de zéro seconde ».
expect(pac.lock, isNull);
});
test('un cycle antérieur du même banc porte un verrou minStateHold', () {
// Même installation, trois cycles plus tôt. Sans ce second dump, la lecture du
// verrou ne serait éprouvée que sur une charge utile fabriquée.
final tel = LoadTelemetry.fromJson(
_fixture('loadtelemetry_hems75_v3_locked.json', 'telemetry'));
final pac = tel.entryFor('pac-terrain')!;
expect(pac.lock!.kind, 'minStateHold');
expect(pac.lock!.remainingS, 60);
expect(tel.budget!.identityHolds, isTrue);
});
test('l\'identité du budget annoncée par le plugin se vérifie sur le dump réel', () {
// surplusW + recreditedW − allocatedW == remainingW. Sans le terme de recrédit,
// l'égalité serait fausse dès qu'une charge consomme déjà — le cas normal.
final tel =
LoadTelemetry.fromJson(_fixture('loadtelemetry_hems75_v3.json', 'telemetry'));
expect(tel.budget!.identityHolds, isTrue);
});
});
// ───────────────────────────────────────────────────────────────────────────
group('champs omis, jamais nuls', () {
test('timestamp absent = aucun cycle, pas un cycle daté de l\'époque zéro', () {
final tel = LoadTelemetry.fromJson({'degradedMode': false, 'loads': []});
expect(tel.hasNeverRun, isTrue);
expect(tel.timestamp, isNull);
expect(tel.ageAt(DateTime.now()), isNull);
});
test('budget absent = mode dégradé, pas un budget de zéro watt', () {
final tel = LoadTelemetry.fromJson({
'degradedMode': true,
'timestamp': '2026-08-25T16:47:00Z',
'loads': [],
});
expect(tel.degradedMode, isTrue);
expect(tel.budget, isNull);
});
test('lock, mechanism et faultCode absents ne fabriquent aucune valeur', () {
final e = LoadTelemetryEntry.fromJson({
'loadId': 'x',
'allocatedW': 0,
'available': true,
'decision': {'code': 'SURPLUS_INSUFFICIENT', 'params': {'budgetW': 12}},
});
expect(e.lock, isNull);
expect(e.mechanism, isNull);
expect(e.faultCode, isNull);
expect(e.inFault, isFalse);
});
});
// ───────────────────────────────────────────────────────────────────────────
group('§7-6 — fraîcheur', () {
LoadTelemetry at(String iso) => LoadTelemetry.fromJson({
'degradedMode': false,
'timestamp': iso,
'loads': [],
});
test('le seuil est supérieur au battement de cœur du plugin', () {
// Le plugin émet une notification au moins toutes les 60 s et le timestamp avance
// d'un cycle par minute (vérifié sur .75 : 12:37 → 12:38 → 12:39). Un seuil ≤ 60 s
// crierait « données anciennes » sur une installation parfaitement vivante.
expect(kTelemetryStaleAfter.inSeconds, greaterThan(60));
});
test('un cycle d\'il y a deux minutes n\'est pas encore ancien', () {
final now = DateTime.utc(2026, 8, 25, 16, 49);
expect(at('2026-08-25T16:47:00Z').ageAt(now), const Duration(minutes: 2));
expect(at('2026-08-25T16:47:00Z').ageAt(now)! > kTelemetryStaleAfter, isFalse);
});
test('un arbitre figé depuis dix minutes dépasse le seuil', () {
final now = DateTime.utc(2026, 8, 25, 16, 57);
expect(at('2026-08-25T16:47:00Z').ageAt(now)! > kTelemetryStaleAfter, isTrue);
});
test('un âge négatif (horloges désynchronisées) est ramené à zéro, pas au futur', () {
final now = DateTime.utc(2026, 8, 25, 16, 40);
expect(at('2026-08-25T16:47:00Z').ageAt(now), Duration.zero);
});
});
// ───────────────────────────────────────────────────────────────────────────
group('§7-7 — charge en défaut', () {
// Charge utile SYNTHÉTIQUE, mais recopiée de ce que `.75` a réellement publié le
// 2026-08-25 pendant le passage de `tools/rpc/fault_probe.dart` : une charge
// etmvariableload dont le ThingId n'existe pas. Le dump brut n'a pas été conservé
// (le défaut n'a pas été reprovoqué pour ça) — `--dump` le capturera au prochain
// passage.
LoadTelemetryEntry faulty() => LoadTelemetryEntry.fromJson({
'loadId': '{00000000-dead-4bee-8000-000000000042}',
'allocatedW': 0,
'available': false,
'faultCode': 'WRITE_FAILED',
'decision': {
'code': 'LOAD_UNAVAILABLE',
'params': {'frozenW': 0},
},
});
test('une charge indisponible se lit comme en défaut', () {
final e = faulty();
expect(e.inFault, isTrue);
expect(e.faultCode, 'WRITE_FAILED');
expect(e.allocatedW, 0);
});
test('le défaut et son motif se rendent en français, sans le code brut', () {
final e = faulty();
expect(faultText(t, e.faultCode!), contains('écriture'));
expect(decisionText(t, e.decision), isNot(contains('LOAD_UNAVAILABLE')));
});
test('le code de défaut prime sur l\'absence de mécanisme', () {
// Observé sur `.75` : le Thing manquant fait échouer l'écriture, l'échelle
// s'épuise, et `m_faulted` masque THING_MISSING. L'app affiche ce que la box dit,
// sans corriger le diagnostic à sa place.
final e = faulty();
expect(e.mechanism, isNull);
expect(faultText(t, e.faultCode!), isNot(faultText(t, 'THING_MISSING')));
});
});
// ───────────────────────────────────────────────────────────────────────────
group('§7-8 — rendu des codes, et repli sur l\'inconnu', () {
test('les motifs du catalogue se rendent en français avec leurs paramètres', () {
final phrase = decisionText(
t,
const LoadDecision(
code: 'SURPLUS_SETPOINT',
params: {'budgetW': 11897, 'setpointW': 3500, 'stepped': true}),
);
expect(phrase, contains('palier'));
expect(phrase, isNot(contains('SURPLUS_SETPOINT')));
});
test('`stepped` distingue deux phrases, et son absence ne prétend ni l\'une ni l\'autre',
() {
String render(Map<String, dynamic> p) =>
decisionText(t, LoadDecision(code: 'SURPLUS_SETPOINT', params: p));
final stepped = render({'budgetW': 100, 'setpointW': 50, 'stepped': true});
final plain = render({'budgetW': 100, 'setpointW': 50, 'stepped': false});
final absent = render({'budgetW': 100, 'setpointW': 50});
expect(stepped, isNot(plain));
expect(absent, plain);
});
test('BELOW_MIN_POWER — deux variantes, départagées par `state`', () {
// Premier motif ajouté depuis que le catalogue existe. Le sens porté est précis :
// il Y A du surplus, mais pas au bon format.
final watts = decisionText(
t,
const LoadDecision(
code: 'BELOW_MIN_POWER',
params: {'budgetW': 320, 'minPowerW': 500}),
);
final etats = decisionText(
t,
const LoadDecision(
code: 'BELOW_MIN_POWER',
params: {'budgetW': 900, 'minPowerW': 1500, 'state': 3}),
);
expect(watts, contains('palier'));
expect(watts, isNot(contains('BELOW_MIN_POWER')));
expect(etats, contains('état 3'));
expect(etats, isNot(watts));
});
test('BELOW_MIN_POWER ne se confond pas avec « il n\'y a rien »', () {
// SURPLUS_INSUFFICIENT et SG_NORMAL ne sortent plus que pour un budget ≤ 0. Les
// confondre ferait chercher un défaut de production là où il n'y a qu'une
// granularité de commande.
final rien = decisionText(
t, const LoadDecision(code: 'SURPLUS_INSUFFICIENT', params: {'budgetW': 0}));
final malForme = decisionText(
t,
const LoadDecision(
code: 'BELOW_MIN_POWER', params: {'budgetW': 320, 'minPowerW': 500}));
expect(rien, isNot(malForme));
expect(rien.toLowerCase(), contains('aucun surplus'));
});
test('le repli AURAIT tenu sur ce motif — code connu, paramètre manquant', () {
// C'est exactement le cas que le repli couvre, et le premier vrai à se présenter :
// une box en avance sur l'app. Sans `minPowerW`, on ne fabrique pas une phrase
// approximative — on rend le code et ses paramètres.
final phrase = decisionText(
t,
const LoadDecision(code: 'BELOW_MIN_POWER', params: {'budgetW': 320}),
);
expect(phrase, contains('BELOW_MIN_POWER'));
expect(phrase, contains('budgetW = 320'));
});
test('réserve batterie — la charge utile RÉELLE du plugin est rendue, pas le code', () {
// Le motif est publié depuis `+etm22` avec CES trois clés. Celles que l'app
// cherchait avant (`batteryLevel`, `threshold`) n'ont jamais existé côté plugin :
// le motif sortait donc en repli, c'est-à-dire en anglais brut, sur le seul cas que
// le client ne peut déduire d'aucune mesure.
final phrase = decisionText(
t,
const LoadDecision(
code: 'BATTERY_RESERVE',
params: {'socPercent': 47, 'reservePercent': 90, 'withheldW': 3200},
),
);
expect(phrase, isNot(contains('BATTERY_RESERVE')),
reason: 'le motif tombe en repli : les clés cherchées ne sont pas les bonnes');
expect(phrase, contains('47'));
expect(phrase, contains('90'));
// Le chiffre RETENU mène la phrase : c'est lui qui rend la cause lisible, là où un
// pourcentage de SOC se subit. Le plugin en fait la condition d'émission du motif.
expect(phrase, contains('3'));
expect(phrase.toLowerCase(), isNot(contains('aucun surplus')));
});
test('réserve batterie — les clés d\'AVANT ne réactivent pas la phrase par accident',
() {
// Un motif portant les anciennes clés n'a pas de quoi composer : il doit sortir en
// repli, pas en phrase amputée qui aurait l'air d'une donnée.
final phrase = decisionText(
t,
const LoadDecision(
code: 'BATTERY_RESERVE', params: {'batteryLevel': 50, 'threshold': 90}),
);
expect(phrase, contains('réserve batterie'),
reason: 'le code EST connu — c\'est la variante courte qui doit sortir');
expect(phrase, isNot(contains('50')));
expect(phrase, isNot(contains('90')));
});
test('réserve batterie — une box sans `withheldW` garde une phrase entière', () {
final phrase = decisionText(
t,
const LoadDecision(
code: 'BATTERY_RESERVE', params: {'socPercent': 47, 'reservePercent': 90}),
);
expect(phrase, contains('47'));
expect(phrase, contains('90'));
expect(phrase, isNot(contains('BATTERY_RESERVE')));
});
test('la phrase de la réserve nomme la CAUSE, pas l\'effet', () {
// Elle est écrite et relue d'avance : c'est la partie difficile, et la brancher le
// jour venu ne doit pas être l'occasion de la rédiger dans l'urgence.
final avec = t.decisionBatteryReserve(50, 90);
expect(avec, contains('réserve batterie'));
expect(avec, contains('surplus'));
expect(avec.toLowerCase(), isNot(startsWith('rien')),
reason: 'décrire l\'effet ferait chercher une panne');
expect(t.decisionBatteryReserveShort, contains('réserve batterie'));
});
test('le crochet du motif porte le code RÉELLEMENT publié par le plugin', () {
// Source d'autorité : `etm/types/decisionreason.h`, `DecisionCode::BatteryReserve`.
// Un crochet vide ne plantait pas — il rendait le motif en anglais brut, ce qui est
// pire : ça a l'air d'un défaut de la box.
expect(kCodesReserveBatterie, contains('BATTERY_RESERVE'));
});
test('un code absent de l\'ARB s\'affiche en repli lisible, sans plantage', () {
final phrase = decisionText(
t,
const LoadDecision(code: 'FUTURE_CODE_2027', params: {'budgetW': 42, 'a': true}),
);
// Une box en avance sur l'app est un cas normal en parc déployé.
expect(phrase, contains('FUTURE_CODE_2027'));
expect(phrase, contains('budgetW = 42'));
expect(phrase, isNot(isEmpty));
});
test('les paramètres du repli sont triés : deux rendus du même motif sont identiques',
() {
final a = formatParams({'zW': 1, 'aW': 2});
final b = formatParams({'aW': 2, 'zW': 1});
expect(a, b);
expect(a, ' (aW = 2, zW = 1)');
});
test('un code connu privé d\'un paramètre attendu bascule en repli, pas en phrase trouée',
() {
// « Consigne de W servie par le surplus » aurait l'air d'une donnée ; le code brut
// a l'air de ce qu'il est.
final phrase = decisionText(
t, const LoadDecision(code: 'SG_FORCED', params: {'budgetW': 10}));
expect(phrase, contains('SG_FORCED'));
});
test('tous les codes du catalogue fermé ont une phrase, aucun ne tombe en repli', () {
const catalogue = <String, Map<String, dynamic>>{
'EV_SURPLUS': {},
'EV_SPOT_MARKET': {},
'EV_DEADLINE': {},
'EV_ECO_MIN': {},
'EV_IDLE': {},
'LOAD_UNAVAILABLE': {'frozenW': 500},
'LOCK_MIN_ON': {'appliedW': 500, 'budgetW': 100},
'LOCK_MIN_OFF': {'budgetW': 100, 'requestedW': 500},
'LOCK_MIN_OFF_CAPPED': {'appliedW': 1, 'budgetW': 2, 'requestedW': 3},
'SURPLUS_SETPOINT': {'budgetW': 1, 'setpointW': 2, 'stepped': false},
'SURPLUS_INSUFFICIENT': {'budgetW': 229},
'LOCK_MIN_STATE_HOLD': {'state': 4},
'SG_FORCED': {'budgetW': 1, 'estimatedW': 3000},
'SG_RECOMMENDED': {'budgetW': 1, 'estimatedW': 1500},
'SG_NORMAL': {'budgetW': 1},
'BELOW_MIN_POWER': {'budgetW': 149, 'minPowerW': 1500},
'BATTERY_RESERVE': {
'socPercent': 47,
'reservePercent': 90,
'withheldW': 3200,
},
'DEGRADED_L2': {},
'SAFE_STATE_RELAY': {},
'SAFE_STATE_SETPOINT': {},
'SAFE_STATE_SG_READY': {},
};
for (final entry in catalogue.entries) {
final phrase =
decisionText(t, LoadDecision(code: entry.key, params: entry.value));
expect(phrase, isNot(contains(entry.key)),
reason: '${entry.key} tombe en repli alors qu\'il est au catalogue');
}
});
test('défauts, verrous et mécanismes ont eux aussi leur repli', () {
expect(faultText(t, 'WRITE_FAILED'), isNot(contains('WRITE_FAILED')));
expect(faultText(t, 'THING_MISSING'), isNot(contains('THING_MISSING')));
expect(faultText(t, 'UNUSABLE_ENCODING'), isNot(contains('UNUSABLE_ENCODING')));
expect(faultText(t, 'FUTURE_FAULT'), contains('FUTURE_FAULT'));
expect(lockText(t, const LoadLock(kind: 'minOff', remainingS: 60)), contains('60'));
expect(lockText(t, const LoadLock(kind: 'futureLock', remainingS: 5)),
contains('futureLock'));
expect(
mechanismText(t, LoadMechanism(const {'stageW': 3500, 'maxStageW': 3500}, 'relay')),
contains('3'));
expect(mechanismText(t, LoadMechanism(const {}, 'futureMechanism')),
contains('futureMechanism'));
});
});
// ───────────────────────────────────────────────────────────────────────────
group('3g-1 — `funding`, et les bornes entrent dans loads[]', () {
// Charge utile calquée sur `.75` en `+etm22` : une borne financée au surplus, une
// seconde financée au RÉSEAU par le proxy (échéance de départ).
Map<String, dynamic> payload() => {
'degradedMode': false,
'timestamp': '2026-08-27T05:23:00Z',
'budget': {
'surplusW': 2000.0,
'evReservedW': 3680.0,
'recreditedW': 0.0,
'allocatedW': 2500.0,
'remainingW': -500.0,
},
'loads': [
{
'loadId': 'chauffe-eau',
'allocatedW': 2500.0,
'available': true,
'funding': 'surplus',
'mechanism': {'kind': 'relay', 'stageW': 2500.0, 'maxStageW': 3500.0},
'decision': {
'code': 'SURPLUS_SETPOINT',
'params': {'budgetW': 2559, 'setpointW': 2500, 'stepped': true},
},
},
{
'loadId': 'borne-echeance',
'allocatedW': 3680.0,
'available': true,
'funding': 'grid',
'mechanism': {
'kind': 'evcharger',
'chargingEnabled': true,
'currentA': 16.0,
'phaseCount': 1,
'pluggedIn': true,
},
'decision': {'code': 'EV_DEADLINE', 'params': {}},
},
],
};
test('le financement se lit, et son absence n\'est pas un zéro', () {
final tel = LoadTelemetry.fromJson(payload());
expect(tel.entryFor('chauffe-eau')!.funding, 'surplus');
expect(tel.entryFor('borne-echeance')!.funding, 'grid');
// Omis en mode dégradé — sans plan, il n'y a pas de financement.
final sansPlan = LoadTelemetryEntry.fromJson(const {
'loadId': 'x',
'allocatedW': 0.0,
'available': true,
});
expect(sansPlan.funding, isNull);
// …et « omis » se replie sur surplus : c'est le seul financement qui existait avant
// ce champ, donc l'identité reste vérifiable sur une box antérieure.
expect(sansPlan.isSurplusFunded, isTrue);
});
test('sommer sans filtrer sur `funding` casse l\'identité du budget', () {
final tel = LoadTelemetry.fromJson(payload());
// La somme BRUTE compte la borne servie au réseau et ne retombe sur rien.
final brute = tel.loads.fold<double>(0, (a, e) => a + e.allocatedW);
expect(brute, 6180);
expect(brute, isNot(closeTo(tel.budget!.allocatedW, 1)));
// Filtrée, elle retombe exactement sur `budget.allocatedW` — et la puissance de la
// borne se retrouve, elle, dans `budget.evReservedW`.
expect(tel.surplusAllocatedW, 2500);
expect(tel.surplusIdentityHolds, isTrue);
expect(tel.budget!.evReservedW, 3680);
});
test('sans budget, l\'identité ne rend pas « faux » mais « rien à réconcilier »', () {
final degrade = LoadTelemetry.fromJson(const {
'degradedMode': true,
'loads': <Map<String, dynamic>>[],
});
expect(degrade.surplusIdentityHolds, isNull);
});
test('pour une BORNE, l\'allocation n\'est pas une commande — pour l\'ECS, si', () {
final tel = LoadTelemetry.fromJson(payload());
// La réserve est ciblée : elle ne doit PAS déborder sur les charges pilotées, où
// l'allocation publiée est bien l'ordre émis.
expect(tel.entryFor('chauffe-eau')!.allocationIsCommand, isTrue);
expect(tel.entryFor('borne-echeance')!.allocationIsCommand, isFalse);
});
test('l\'état réel de la borne vient du Thing, pas du motif d\'arbitrage', () {
// Le cas relevé sur `.75` : l'arbitre refuse (« 0 W », BELOW_MIN_POWER) et le proxy
// commande ON à 6 A dans le même cycle. Les deux se contredisent, et c'est
// `mechanism` qui dit ce que la borne FAIT.
final borne = LoadTelemetryEntry.fromJson(const {
'loadId': 'borne-surplus',
'allocatedW': 0.0,
'available': true,
'funding': 'surplus',
'mechanism': {
'kind': 'evcharger',
'chargingEnabled': true,
'currentA': 6.0,
'phaseCount': 1,
'pluggedIn': true,
},
'decision': {
'code': 'BELOW_MIN_POWER',
'params': {'budgetW': 1096, 'minPowerW': 4140},
},
});
expect(borne.allocatedW, 0);
expect(borne.mechanism!.chargingEnabled, isTrue);
expect(borne.mechanism!.currentA, 6);
expect(borne.allocationIsCommand, isFalse,
reason: 'écrire « la borne ne charge pas » ici serait faux');
});
});
}