etm-powersync-app/test/ev_mode_buttons_test.dart
Patrick Schurig 5a51582729 fix(ev): « Boost » devient « Manuel » — le nom promettait le contraire du comportement
Décision de Patrick : trois modes suffisent — Manuel, PV, Min+PV. Le boost n'est
pas un quatrième mode, c'est la dérogation (+etm61).

LE FAIT. Le bouton écrivait `ChargingModeNormal`, qui sort la borne de
l'arbitrage et lui restitue ses valeurs manuelles stockées —
manualChargingEnabled, manualMaxChargingCurrent, manualDesiredPhaseCount. Sur
.75 elles valaient false / 6 A / 3 phases : mesuré le 2026-09-08 à 11:09:51,
l'appui a ARRÊTÉ la charge et armé un verrou de 300 s. Pas une régression —
c'est ce que Normal a toujours fait. C'est le nom qui mentait.

Renommé jusque dans le code (`ChargingMode.boost` -> `ChargingMode.manuel`), pas
seulement sur l'étiquette : un identifiant faux reste un piège pour le prochain
lecteur, et c'est précisément ce qui a duré des mois ici. L'ordre met Manuel en
tête, parce que ChargingModeNormal est aussi le mode d'office d'une borne
détectée.

ET LE RENOMMAGE NE SUFFISAIT PAS. « Manuel » dit QUI commande, pas ce que la
borne va faire : tant que l'app n'offre pas de régler ces valeurs stockées, le
mode veut dire « ce qui traînait la dernière fois ». Passer en manuel demande
donc confirmation, et la confirmation dit la conséquence — la charge peut
s'arrêter — AVANT, pas après.

⚠️ La confirmation ne se déclenche pas sur un ré-appui du mode déjà en place :
une confirmation qu'on apprend à cliquer sans lire ne protège plus de rien. Un
test le fige.

Vérifié sur .75 (1.15.2+etm61) : SetLoadDerogation et ClearLoadDerogation sont
dans Introspect, avec derogationEnded / durationS / restoredMode. Le contrat de
la dérogation est écrit dans CLAUDE.md — durée obligatoire bornée [60, 21600],
état lu dans decision.code == "DEROGATION_ACTIVE" et jamais dans le mode, fin au
niveau de la trame avec six causes. Aucun bouton de boost n'est écrit : c'est le
lot suivant.

Et une sonde pour le moteur, faite au passage : la V2C est bien restée en
ChargingModeNormal et elle est ABSENTE de loads[] — hors arbitrage. Leur
hypothèse est confirmée sans redémarrage.

339 tests.

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

199 lines
6.9 KiB
Dart

/// Les trois boutons de mode — **le seul lieu d'écriture de `SetChargingInfo`**.
///
/// Ils ont vécu sur la tuile des favoris, sur des pastilles de 7 à 10 px qui avalaient les
/// refus. Ils sont depuis le 2026-08-28 sur l'écran « Configurer » de la borne, en un seul
/// exemplaire : deux endroits qui écrivent la même chose sont deux comportements qui
/// divergent.
library;
import 'package:etm_powersync_app/models/energy_data.dart' show ChargingMode;
import 'package:etm_powersync_app/models/ev_charger_live.dart';
import 'package:etm_powersync_app/widgets/ev_mode_buttons.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
EvChargerLive _v2c({String? apiMode = 'ChargingModeNormal'}) => EvChargerLive(
thingId: '4d1adafd-cafc-4813-93b1-ca8933ce71ab',
name: 'V2C Trydan',
pluggedIn: false,
charging: false,
currentPowerW: 0,
apiMode: apiMode,
);
Widget _harness({
required EvChargerLive live,
Future<String?> Function(ChargingMode)? onSetMode,
}) =>
MaterialApp(
home: Scaffold(
body: EvModeButtons(
live: live,
onSetMode: onSetMode ?? (_) async => null,
),
),
);
void main() {
testWidgets('un refus de la box est affiché, avec son code', (tester) async {
await tester.pumpWidget(_harness(
live: _v2c(),
onSetMode: (_) async => 'EnergyErrorInvalidParameter',
));
await tester.tap(find.text('PV'));
await tester.pump(); // écriture en cours
await tester.pump(); // réponse
await tester.pump(); // SnackBar
expect(find.textContaining('EnergyErrorInvalidParameter'), findsOneWidget);
expect(find.textContaining('a refusé'), findsOneWidget);
});
testWidgets('un succès n\'affiche rien — c\'est la box qui dira le mode',
(tester) async {
var appels = 0;
await tester.pumpWidget(_harness(
live: _v2c(),
onSetMode: (m) async {
appels++;
return null;
},
));
await tester.tap(find.text('PV'));
await tester.pumpAndSettle();
expect(appels, 1);
expect(find.textContaining('a refusé'), findsNothing);
});
testWidgets('la sélection suit la BOX, pas la dernière commande', (tester) async {
// `_v2c()` est en ChargingModeNormal : c'est Manuel qui doit être allumé, et lui seul,
// quoi qu'on ait tapé juste avant.
await tester.pumpWidget(_harness(live: _v2c()));
await tester.tap(find.text('PV'));
await tester.pumpAndSettle();
final manuel = tester.widget<OutlinedButton>(
find.widgetWithText(OutlinedButton, 'Manuel'));
final pv =
tester.widget<OutlinedButton>(find.widgetWithText(OutlinedButton, 'PV'));
expect(manuel.style?.backgroundColor?.resolve({}),
isNot(Colors.transparent),
reason: 'Manuel est le mode que la box porte');
expect(pv.style?.backgroundColor?.resolve({}), Colors.transparent,
reason: 'PV vient d\'être demandé, mais la box ne l\'a pas encore dit');
});
testWidgets('un mode non lu n\'allume RIEN', (tester) async {
// Trois boutons éteints valent mieux qu'un allumé au hasard.
await tester.pumpWidget(_harness(live: _v2c(apiMode: null)));
for (final label in ['Manuel', 'PV', 'Min+PV']) {
final b = tester
.widget<OutlinedButton>(find.widgetWithText(OutlinedButton, label));
expect(b.style?.backgroundColor?.resolve({}), Colors.transparent);
}
});
testWidgets('les cibles tactiles font au moins 44 px', (tester) async {
await tester.pumpWidget(_harness(live: _v2c()));
for (final label in ['Manuel', 'PV', 'Min+PV']) {
final taille = tester.getSize(find.ancestor(
of: find.text(label),
matching: find.byType(OutlinedButton),
));
expect(taille.height, greaterThanOrEqualTo(44),
reason: '$label : cible tactile trop petite');
}
});
group('« Manuel » se confirme — il peut ARRÊTER la charge', () {
// Le bouton s'appelait « Boost ». `ChargingModeNormal` sort la borne de l'arbitrage et
// lui restitue ses valeurs manuelles stockées : sur `.75` elles valaient
// `manualChargingEnabled: false`, et l'appui a stoppé la charge le 2026-09-08 à
// 11:09:51, avec un verrou de 300 s derrière.
//
// Renommer ne suffisait pas : « Manuel » dit QUI commande, pas ce que la borne va
// faire. Tant que l'app n'offre pas de régler ces valeurs, le mode veut dire « ce qui
// traînait la dernière fois ».
testWidgets('l\'appui n\'écrit RIEN tant qu\'on n\'a pas confirmé', (tester) async {
var appels = 0;
await tester.pumpWidget(_harness(
live: _v2c(apiMode: 'ChargingModeEco'),
onSetMode: (_) async {
appels++;
return null;
},
));
await tester.tap(find.text('Manuel'));
await tester.pumpAndSettle();
expect(find.textContaining('Rendre la main à la borne'), findsOneWidget);
expect(appels, 0, reason: 'rien ne part avant que l\'utilisateur ait dit oui');
expect(find.textContaining('la charge peut s\'arrêter'), findsOneWidget,
reason: 'la conséquence se dit AVANT, pas après');
await tester.tap(find.text('Annuler'));
await tester.pumpAndSettle();
expect(appels, 0);
});
testWidgets('confirmé, il écrit une fois', (tester) async {
var appels = 0;
await tester.pumpWidget(_harness(
live: _v2c(apiMode: 'ChargingModeEco'),
onSetMode: (_) async {
appels++;
return null;
},
));
await tester.tap(find.text('Manuel'));
await tester.pumpAndSettle();
await tester.tap(find.text('Passer en manuel'));
await tester.pumpAndSettle();
expect(appels, 1);
});
testWidgets('déjà en manuel, aucune question — rien ne change', (tester) async {
// Le contrôle qui MORD : la confirmation ne doit pas se déclencher sur un ré-appui
// du mode déjà en place, sinon elle devient un péage qu'on apprend à cliquer sans
// lire — et une confirmation qu'on ne lit plus ne protège plus de rien.
var appels = 0;
await tester.pumpWidget(_harness(
live: _v2c(), // déjà ChargingModeNormal
onSetMode: (_) async {
appels++;
return null;
},
));
await tester.tap(find.text('Manuel'));
await tester.pumpAndSettle();
expect(find.textContaining('Rendre la main'), findsNothing);
expect(appels, 1);
});
testWidgets('les autres modes ne demandent rien', (tester) async {
var appels = 0;
await tester.pumpWidget(_harness(
live: _v2c(),
onSetMode: (_) async {
appels++;
return null;
},
));
await tester.tap(find.text('Min+PV'));
await tester.pumpAndSettle();
expect(find.textContaining('Rendre la main'), findsNothing);
expect(appels, 1);
});
});
}