etm-powersync-app/test/ev_favorite_tile_test.dart
Patrick Schurig 51a1ab929c feat(config): retirer une charge orpheline, et régler le mode de recharge à un seul endroit
Deux manques trouvés en usage réel sur téléphone, contre .75.

── 1. Retirer une charge dont l'appareil n'existe plus ────────────────────────

Le plugin crée d'office une entrée par borne détectée mais ne la retire pas quand le Thing
s'en va : la Terra AC supprimée de nymea tenait encore le rang 4 deux jours plus tard, et
l'app savait le dire sans savoir y remédier.

Le bouton n'apparaît que sur THING_MISSING. Sur une charge vivante le geste n'aurait pas de
sens — le plugin recrée l'entrée au cycle suivant, et un bouton qui se défait tout seul
ferait douter de la box.

Le chemin est celui qui était demandé, et pour la raison qui était donnée :
  · LIRE (GetLoadConfig, frais — pas l'état en mémoire, la box a pu changer d'avis)
  · PATCHER (payloadWithoutLoad : les autres charges recopiées VERBATIM, champ par champ)
  · RÉÉCRIRE (SetLoadConfig, qui remplace l'ensemble)

Jamais une reconstruction depuis le modèle typé : un champ qu'une box plus récente porterait
et que l'app ne connaît pas serait perdu pour TOUTES les charges d'un coup, en silence. Les
rangs ne sont pas renumérotés — le trou laissé (1,2,3,5) ne change l'ordre de personne,
alors qu'une renumérotation réécrirait le rang de charges que personne n'a touchées.

Rien à moitié écrit : un refus laisse la configuration locale intacte (aucune adoption avant
confirmation) et la box refuse l'ensemble ou l'accepte en entier. Le geste est refusé tant
qu'un brouillon est en cours — sinon la suppression emporterait des modifications non
enregistrées. La confirmation annonce ce que l'utilisateur ne peut pas deviner : retirer une
charge réécrit toutes les autres.

── 2. Le mode de recharge, un seul lieu d'écriture ───────────────────────────

Les trois boutons quittent la tuile des favoris pour l'écran « Configurer » de la borne :
c'est l'écran de mise en service, celui où on découvre qu'une borne neuve est en
ChargingModeNormal. Deux endroits qui écrivent SetChargingInfo seraient deux comportements
qui divergent.

La tuile des favoris garde la LECTURE et l'avertissement « hors arbitrage » : c'est le
client qui ouvre ses favoris, et il doit voir que sa borne n'est pas pilotée même s'il doit
aller ailleurs pour le corriger.

⚠️ Bug latent trouvé en déplaçant : setChargingInfo écrivait toujours sur la PREMIÈRE borne
configurée (_findEvChargerId). Sur le banc qui en porte trois, régler la V2C depuis son
propre écran aurait réglé la wallbox simulée. La borne est maintenant visée explicitement,
et evChargerFor() réconcilie les deux graphies d'identifiant (§9 du contrat de données).

181 tests (+12), 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 14:36:50 +02:00

149 lines
6.4 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('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 ['PV', 'Min+PV', 'Boost']) {
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);
});
});
}