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

111 lines
3.8 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 Boost 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 boost = tester.widget<OutlinedButton>(
find.widgetWithText(OutlinedButton, 'Boost'));
final pv =
tester.widget<OutlinedButton>(find.widgetWithText(OutlinedButton, 'PV'));
expect(boost.style?.backgroundColor?.resolve({}),
isNot(Colors.transparent),
reason: 'Boost 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 ['PV', 'Min+PV', 'Boost']) {
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 ['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');
}
});
}