etm-powersync-app/test/ev_boost_button_test.dart
Patrick Schurig b0b6666bc8 feat(ev): le boost est une DÉROGATION — bouton d'action, durée obligatoire, six fins
Le lot complet côté app, écrit sur le contrat vérifié dans Introspect sur .75 en
1.15.2+etm61 : SetLoadDerogation { loadId, durationS, o:targetWh } /
ClearLoadDerogation { loadId }, et derogationEnded[] { cause, loadId,
o:restoredMode } au niveau de la trame.

CE QUI EST UN BOUTON D'ACTION, PAS UN MODE. Une dérogation se superpose au mode
courant et s'efface ; elle ne le remplace pas. La mettre dans le sélecteur
ferait croire qu'on en sort en choisissant autre chose.

UNE DURÉE, TOUJOURS. Bornée [60, 21600] côté moteur. L'écran n'offre AUCUNE
option « jusqu'à annulation » — pas parce que l'appel échouerait, mais parce que
la promesse serait mauvaise : une dérogation qui ne sait pas dire quand elle
finit ne devrait pas exister (LM-1500). Le filet reste armé même avec une cible,
parce qu'une cible repose sur un instrument et qu'un instrument peut se taire.

L'ÉTAT NE SE LIT PAS DANS LE MODE. Si la borne était en manuel, le moteur force
Eco le temps de la dérogation : GetChargingInfos montrerait alors un mode que
personne n'a choisi. La vérité est sur la charge —
LoadTelemetryEntry.enDerogation.

Trois refus d'inventer, chacun figé par un test :
- entrée de télémétrie absente -> le bouton se TAIT. « Pas d'entrée » n'est pas
  « pas de boost », c'est « on ne sait pas » ;
- échéance dépassée alors que la box annonce encore la dérogation -> on la NOMME
  au lieu de rabattre à « 00:00 » : c'est une contradiction entre deux champs de
  la même trame, et le zéro l'effacerait ;
- cause de fin inconnue -> aucun libellé. Une box plus récente peut en publier
  une septième ; lui coller la phrase d'une autre serait pire.

Et `EnergyErrorInvalidParameter` sur l'arrêt ne crie pas au refus : la
dérogation a pu finir seule pendant l'appui, et une course que l'utilisateur ne
peut pas éviter n'est pas une erreur à lui montrer.

⚠️ boundBy a DEUX catalogues : drawCap|loadCeiling sur la dérogation, mais
« minimum » sur ECO_FLOOR_GRID — relevé sur la V2C aujourd'hui. Même nom, sens
différent : aucune traduction partagée.

⚠️ Écrit d'après le contrat, PAS observé : DEROGATION_ACTIVE, endsAt et boundBy
ne sont pas dans Introspect (les params d'une décision sont une carte libre), et
aucune dérogation n'a tourné pendant une sonde. Tous les champs restent
nullables. Noté aussi qu'Introspect SOUS-DÉCLARE — il annonce draw{committedW}
seul alors que la box publie authorisedW/binding/bindingPhase/perPhaseBound —
donc son silence ne prouve rien.

La feuille de choix défile : 47 px de débordement trouvés par le test sur une
surface courte, donc réels sur un téléphone à police agrandie.

358 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-08 13:59:32 +02:00

149 lines
5.7 KiB
Dart

/// **Le bouton de boost — ce qu'il refuse de faire compte autant que ce qu'il fait.**
library;
import 'package:etm_powersync_app/models/load_telemetry.dart';
import 'package:etm_powersync_app/widgets/ev_boost_button.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
LoadTelemetryEntry _entree({Map<String, dynamic>? decision}) =>
LoadTelemetryEntry.fromJson({
'loadId': '{4d1adafd}',
'allocatedW': 4140.0,
'available': true,
'decision': decision ??
{'code': 'ECO_FLOOR_GRID', 'params': {'gridW': 4140}},
});
Widget _harness({
LoadTelemetryEntry? entry,
Future<String?> Function({required int durationS, double? targetWh})? onSet,
Future<String?> Function()? onClear,
}) =>
MaterialApp(
home: Scaffold(
body: EvBoostButton(
entry: entry,
onSet: onSet ?? ({required durationS, targetWh}) async => null,
onClear: onClear ?? () async => null,
),
),
);
void main() {
testWidgets('sans entrée de télémétrie, le bouton se TAIT', (tester) async {
// « Pas d'entrée » n'est pas « pas de boost » : c'est « on ne sait pas ». Afficher un
// bouton de boost sur une borne qu'on ne voit pas arbitrée promettrait une action dont
// on ignore si elle a un sens.
await tester.pumpWidget(_harness(entry: null));
expect(find.byType(OutlinedButton), findsNothing);
expect(find.textContaining('Boost'), findsNothing);
});
testWidgets('au repos, il propose des durées — et AUCUNE illimitée', (tester) async {
await tester.pumpWidget(_harness(entry: _entree()));
await tester.tap(find.text('Boost…'));
await tester.pumpAndSettle();
for (final d in ['30 min', '1 h', '2 h', '4 h', '6 h']) {
expect(find.text(d), findsOneWidget);
}
// Le contrôle qui MORD : une dérogation qui ne sait pas dire quand elle finit ne doit
// pas exister (LM-1500), et le moteur refuserait au-delà de 6 h de toute façon.
for (final interdit in ['Illimité', 'Jusqu\'à annulation', '12 h', '24 h']) {
expect(find.text(interdit), findsNothing);
}
});
testWidgets('choisir une durée pose la dérogation avec CETTE durée', (tester) async {
int? recu;
await tester.pumpWidget(_harness(
entry: _entree(),
onSet: ({required durationS, targetWh}) async {
recu = durationS;
return null;
},
));
await tester.tap(find.text('Boost…'));
await tester.pumpAndSettle();
await tester.tap(find.text('2 h'));
await tester.pumpAndSettle();
expect(recu, 7200);
});
group('en cours', () {
LoadTelemetryEntry active({String? endsAt, String? boundBy, double? maxW}) =>
_entree(decision: {
'code': 'DEROGATION_ACTIVE',
'params': {
if (endsAt != null) 'endsAt': endsAt,
if (boundBy != null) 'boundBy': boundBy,
if (maxW != null) 'maxW': maxW,
},
});
testWidgets('l\'état vient de la CHARGE, et offre d\'arrêter', (tester) async {
await tester.pumpWidget(_harness(
entry: active(
endsAt:
DateTime.now().toUtc().add(const Duration(minutes: 42)).toIso8601String())));
expect(find.textContaining('Boost —'), findsOneWidget);
expect(find.text('Arrêter'), findsOneWidget);
expect(find.text('Boost…'), findsNothing);
});
testWidgets('une échéance DÉPASSÉE se nomme, elle ne se rabat pas à zéro',
(tester) async {
// La box annonce encore la dérogation active alors que son échéance est passée :
// c'est une contradiction entre deux champs de la même trame. « 00:00 » l'effacerait.
await tester.pumpWidget(_harness(
entry: active(
endsAt: DateTime.now()
.toUtc()
.subtract(const Duration(minutes: 3))
.toIso8601String())));
expect(find.textContaining('échéance dépassée'), findsOneWidget);
expect(find.textContaining('00:00'), findsNothing);
});
testWidgets('sans échéance publiée, pas de compte à rebours inventé', (tester) async {
await tester.pumpWidget(_harness(entry: active()));
expect(find.text('Boost en cours'), findsOneWidget);
});
testWidgets('ce qui BRIDE se dit, le reste se tait', (tester) async {
await tester.pumpWidget(
_harness(entry: active(boundBy: 'drawCap', maxW: 7400)));
expect(find.textContaining('limité par l\'abonnement'), findsOneWidget);
expect(find.textContaining('7.4 kW'), findsOneWidget);
// Rien ne bride : « rien ne te bride » n'est pas une information à répéter.
await tester.pumpWidget(_harness(entry: active()));
await tester.pumpAndSettle();
expect(find.textContaining('limité'), findsNothing);
});
testWidgets('un refus « aucune dérogation » ne crie pas au refus', (tester) async {
// Elle a pu finir toute seule pendant l'appui : une course que l'utilisateur ne peut
// pas éviter ne doit pas lui être présentée comme une erreur.
await tester.pumpWidget(_harness(
entry: active(),
onClear: () async => 'EnergyErrorInvalidParameter',
));
await tester.tap(find.text('Arrêter'));
await tester.pumpAndSettle();
expect(find.textContaining('a refusé'), findsNothing);
});
testWidgets('un VRAI refus, lui, se voit', (tester) async {
await tester.pumpWidget(_harness(
entry: active(),
onClear: () async => 'EnergyErrorHardwareFailure',
));
await tester.tap(find.text('Arrêter'));
await tester.pumpAndSettle();
expect(find.textContaining('EnergyErrorHardwareFailure'), findsOneWidget);
});
});
}