etm-powersync-app/test/ev_favorite_tile_test.dart
Patrick Schurig e0264b5ed6 feat(ev): une borne à pleine puissance n'est pas arbitrée — les deux écrans le nomment
Geste de mise en service, signalé par le moteur : toute borne détectée reçoit d'office
ChargingModeNormal, c'est-à-dire pleine puissance HORS arbitrage. Une borne neuve n'est
donc jamais pilotée tant que personne n'a posé un mode éco, et rien ne le disait —
l'installateur pose une borne, elle charge à fond, et il cherche du côté du HEMS un défaut
qui n'existe pas.

La note apparaît aux deux endroits qui comptent, mot pour mot (kEvUnmanagedNote) :
  · la carte de configuration, celle qu'on ouvre à la mise en service ;
  · la tuile des favoris, où les trois boutons de mode sont juste à côté.

⚠️ Elle nomme l'ÉTAT, pas une négligence. La box publie le même chargingMode pour un boost
délibéré et pour une borne jamais réglée : l'app ne peut pas les distinguer, donc elle dit
« pleine puissance, hors arbitrage » et rappelle que c'est aussi le mode d'office. Même
règle que la note de rangs ex æquo — reproductible n'est pas choisi, et on ne fait pas dire
à un écran ce que la donnée ne porte pas. Un mode NON LU ne déclenche rien non plus : on ne
conclut pas d'une absence.

Le piège qui aurait rendu la note invisible est couvert par un test : GetLoadConfig rend
l'id de charge AVEC accolades ({4d1adafd-…}), GetChargingInfos rend l'evChargerId SANS.
Comparer les deux tels quels ne rend jamais rien — en silence : l'écran aurait conclu
« mode non lu » sur une borne parfaitement lue. chargingModeOf() réconcilie les deux.

Six tests montés (trois par écran), dont les deux contrôles négatifs.

Et la réserve sessionEnergy est documentée à sa juste place : le champ est CONSTRUIT par
le plugin V2C 1.15.2+etm13 — il accumule les incréments de chargeEnergy, détecte les
remises à zéro, ne repart qu'à l'ouverture d'une session — donc l'égalité observée hier
s'explique d'elle-même. La réserve tient jusqu'à la trace que la campagne produira
(chargeEnergy à zéro, sessionEnergy qui continue) : aucun avancement affiché d'ici là.

Noté aussi, pour la suite : sessionEnergy n'est PAS historisé (suggestLogging écrasé par
things.conf sur un Thing déjà appairé ; la V2C n'y a que connected, charging, power). La
valeur courante est juste, il n'y aura pas de courbe — le choix des états enregistrés est
côté app, entrée au TODO.

164 tests, flutter analyze inchangé à 27 remarques.

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

192 lines
7.5 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/energy_data.dart' show ChargingMode;
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,
Future<String?> Function(ChargingMode)? onSetMode,
}) =>
MaterialApp(
home: Scaffold(
body: EvFavoriteTile(
live: live,
onSetMode: onSetMode ?? (_) async => null,
),
),
);
/// 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('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('l\'écriture du mode restitue le refus', () {
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('Boost'));
await tester.pumpAndSettle();
expect(appels, 1);
expect(find.textContaining('a refusé'), findsNothing);
// La sélection n'a pas bougé toute seule : la tuile affiche le mode que la box
// portait à l'ouverture, et attend `ChargingInfoChanged` pour changer d'avis.
expect(find.text('Mode Boost'), findsOneWidget);
});
testWidgets('les cibles tactiles font au moins 44 px', (tester) async {
// Les pastilles précédentes faisaient 7 à 10 px, et écrivaient pour de vrai.
await tester.pumpWidget(_harness(live: _v2c()));
for (final label in ['PV', 'Min+PV', 'Boost']) {
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');
expect(taille.width, greaterThanOrEqualTo(44));
}
});
});
}