etm-powersync-app/test/ev_favorite_tile_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

201 lines
8.8 KiB
Dart

/// La tuile « Borne EV » **rendue muette** — éprouvée sur ce qu'elle affiche.
///
/// Le défaut qu'elle corrigeait ne se voyait pas en test de fonction : les trois grandeurs
/// affichées (« 3600 W », « 82 % solaire », « Mode PV ») étaient des valeurs par défaut de
/// `EnergyData`, justes au sens du type et fausses à l'écran. Ces tests montent donc le
/// widget et lisent le texte, comme ceux des notes de configuration.
library;
import 'package:etm_powersync_app/models/ev_charger_live.dart';
import 'package:etm_powersync_app/widgets/ev_favorite_tile.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
Widget _harness({required EvChargerLive? live}) =>
MaterialApp(home: Scaffold(body: EvFavoriteTile(live: live)));
/// La V2C du banc, telle que `.75` la publiait le 2026-08-28 : états `pluggedIn`,
/// `charging`, `currentPower` sur le Thing, mode par `GetChargingInfos`.
EvChargerLive _v2c({
bool? pluggedIn = false,
bool? charging = false,
double? powerW = 0,
String? apiMode = 'ChargingModeNormal',
}) =>
EvChargerLive(
thingId: '4d1adafd-cafc-4813-93b1-ca8933ce71ab',
name: 'V2C Trydan',
pluggedIn: pluggedIn,
charging: charging,
currentPowerW: powerW,
apiMode: apiMode,
);
void main() {
group('borne injoignable — ni le zéro forgé, ni la valeur périmée', () {
// Mesuré sur `.75` le 2026-08-30 : quand le lien Modbus de la V2C tombe, la box
// publie `connected: false`, `pluggedIn: false`, `charging: false`,
// `currentPower: 0` et un `statusMessage`. La voiture, elle, était branchée et en
// charge à 4 356 W. `NymeaService.evChargerFor` jette ces zéros ; ces tests
// vérifient ce que la tuile en fait.
EvChargerLive horsLigne({String? message}) => EvChargerLive(
thingId: '4d1adafd-cafc-4813-93b1-ca8933ce71ab',
name: 'V2C Trydan',
apiMode: 'ChargingModeNormal',
joignable: false,
messageEtat: message,
);
testWidgets('elle dit la coupure, avec la phrase du plugin', (tester) async {
await tester.pumpWidget(_harness(
live: horsLigne(
message: 'Hors ligne depuis 30/08 12:39 — timeout Modbus — '
'vérifier signal WiFi de la borne')));
expect(find.text('Hors ligne'), findsOneWidget);
expect(find.textContaining('timeout Modbus'), findsOneWidget);
// …et surtout PAS ce que la box publiait au même instant.
expect(find.textContaining('Aucun véhicule branché'), findsNothing);
expect(find.textContaining('0 W'), findsNothing);
});
testWidgets('sans phrase du plugin, elle nomme quand même l\'ignorance',
(tester) async {
await tester.pumpWidget(_harness(live: horsLigne()));
expect(find.textContaining('ne sait plus si un véhicule est branché'),
findsOneWidget);
});
testWidgets('le MODE reste lu — il vient de la box, pas de la borne',
(tester) async {
// Un lien coupé n'invalide pas un réglage stocké dans la box : le cacher
// priverait le client de la seule chose encore vraie à l'écran.
await tester.pumpWidget(_harness(live: horsLigne()));
expect(find.textContaining('Mode Manuel'), findsOneWidget);
});
testWidgets('`joignable == null` n\'est PAS hors ligne', (tester) async {
// Une borne qui ne publie pas `connected` ne dit rien de son lien. Supposer la
// panne serait le symétrique exact du zéro forgé.
await tester.pumpWidget(_harness(
live: _v2c(pluggedIn: true, charging: true, powerW: 4356)));
expect(find.text('Hors ligne'), findsNothing);
expect(find.text('4356 W'), findsOneWidget);
});
});
group('la tuile ne montre que ce que la box publie', () {
testWidgets('sans borne, elle le dit — elle ne disparaît pas',
(tester) async {
// Retirer la tuile laisserait un trou dans une liste que le client a composée.
await tester.pumpWidget(_harness(live: null));
expect(find.textContaining('Aucune borne déclarée'), findsOneWidget);
expect(find.textContaining('W'), findsNothing);
});
testWidgets('une borne qui ne publie rien n\'affiche AUCUN zéro',
(tester) async {
await tester.pumpWidget(_harness(
live: _v2c(
pluggedIn: null, charging: null, powerW: null, apiMode: null)));
// Les trois absences se disent, chacune à sa place.
expect(find.text('Puissance non publiée'), findsOneWidget);
expect(find.textContaining('Branchement non publié'), findsOneWidget);
expect(find.text('Mode non lu'), findsOneWidget);
// Et surtout : pas de « 0 W », qui se lirait « la borne ne consomme rien ».
expect(find.textContaining('0 W'), findsNothing);
});
testWidgets('elle affiche la puissance MESURÉE et le mode DE LA BOX',
(tester) async {
await tester.pumpWidget(_harness(
live: _v2c(
pluggedIn: true,
charging: true,
powerW: 3907,
apiMode: 'ChargingModeEco')));
expect(find.text('3907 W'), findsOneWidget);
expect(find.text('En charge'), findsOneWidget);
expect(find.text('Mode PV'), findsOneWidget);
expect(find.text('V2C Trydan'), findsOneWidget);
// Aucune part solaire : la box ne dit pas d'où vient le courant d'une borne.
expect(find.textContaining('solaire'), findsNothing);
});
testWidgets('un mode à échéance se lit comme tel, un mode inconnu aussi',
(tester) async {
await tester.pumpWidget(
_harness(live: _v2c(apiMode: 'ChargingModeEcoMinWithTargetTime')));
expect(find.text('Mode Min+PV + échéance'), findsOneWidget);
// Une box peut porter un mode qu'aucune version d'app ne connaît. Deviner serait
// pire que le nommer.
await tester.pumpWidget(_harness(live: _v2c(apiMode: 'ChargingModeFutur')));
await tester.pump();
expect(find.textContaining('Mode non reconnu'), findsOneWidget);
});
testWidgets('branché mais à l\'arrêt n\'est pas « en charge »',
(tester) async {
await tester.pumpWidget(
_harness(live: _v2c(pluggedIn: true, charging: false, powerW: 0)));
expect(find.textContaining('branché, à l\'arrêt'), findsOneWidget);
// Ici le zéro est une MESURE, pas une absence : la borne publie `currentPower`.
expect(find.text('0 W'), findsOneWidget);
});
});
group('pleine puissance = hors arbitrage, et l\'écran le dit', () {
testWidgets('ChargingModeNormal porte la note — c\'est le mode d\'office',
(tester) async {
// Une borne détectée arrive en ChargingModeNormal : pleine puissance, hors
// arbitrage. Sans cette ligne, l'installateur pose une borne, elle charge à fond,
// et il cherche pourquoi le HEMS l'ignore.
await tester.pumpWidget(_harness(live: _v2c(apiMode: 'ChargingModeNormal')));
expect(find.textContaining('hors arbitrage'), findsOneWidget);
expect(find.textContaining('mode d\'office'), findsOneWidget);
// Elle n'accuse personne : la box publie le même code pour un boost délibéré.
final note = tester
.widget<Text>(find.textContaining('hors arbitrage'))
.data!;
expect(note, isNot(contains('personne')));
expect(note, isNot(contains('oubli')));
});
testWidgets('un mode éco la fait disparaître', (tester) async {
await tester.pumpWidget(_harness(live: _v2c(apiMode: 'ChargingModeEco')));
expect(find.textContaining('hors arbitrage'), findsNothing);
});
testWidgets('mode non lu : aucune note — on ne conclut pas d\'une absence',
(tester) async {
await tester.pumpWidget(_harness(live: _v2c(apiMode: null)));
expect(find.textContaining('hors arbitrage'), findsNothing);
expect(find.text('Mode non lu'), findsOneWidget);
});
});
group('la tuile est en LECTURE SEULE depuis le 2026-08-28', () {
testWidgets('aucun bouton de mode — l\'écriture a un seul lieu, « Configurer »',
(tester) async {
// Deux écrans qui écrivent SetChargingInfo sont deux comportements qui divergent.
await tester.pumpWidget(_harness(live: _v2c()));
for (final label in ['Manuel', 'PV', 'Min+PV']) {
expect(find.widgetWithText(OutlinedButton, label), findsNothing,
reason: '$label ne doit plus être écrivable depuis les favoris');
}
expect(find.byType(OutlinedButton), findsNothing);
});
testWidgets('mais l\'avertissement « hors arbitrage » reste, lui', (tester) async {
// C'est le client qui ouvre ses favoris : il doit voir que sa borne n'est pas
// pilotée, même s'il doit aller ailleurs pour le corriger.
await tester.pumpWidget(_harness(live: _v2c(apiMode: 'ChargingModeNormal')));
expect(find.textContaining('hors arbitrage'), findsOneWidget);
});
});
}