diff --git a/lib/providers/load_config_provider.dart b/lib/providers/load_config_provider.dart index 068dcff..d2b9903 100644 --- a/lib/providers/load_config_provider.dart +++ b/lib/providers/load_config_provider.dart @@ -309,6 +309,88 @@ class LoadConfigProvider extends ChangeNotifier { }); } + /// **Supprime une charge de la configuration** — le seul geste de l'app qui DÉTRUIT. + /// + /// ## Pourquoi il existe + /// + /// 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** : l'entrée survit, occupe un rang, et se publie en `THING_MISSING`. + /// Constaté sur `.75` avec une ABB Terra AC remplacée par une V2C, dont l'entrée tenait + /// encore un rang deux jours plus tard. `ClearLoadFault` n'y peut rien — il n'y a plus + /// d'appareil à qui parler. + /// + /// ## Le chemin, et pourquoi c'est celui-là + /// + /// **LIRE → PATCHER → RÉÉCRIRE**, sur les charges utiles brutes de la box. Jamais une + /// reconstruction depuis le modèle typé : `SetLoadConfig` remplace **tout** l'ensemble, + /// donc supprimer une charge, c'est réécrire toutes les autres. Un champ que le modèle + /// ne connaîtrait pas — box plus récente que l'app — serait perdu pour toutes les + /// charges d'un coup, en silence. + /// + /// La lecture est **fraîche**, pas l'état en mémoire : entre l'ouverture de l'écran et + /// le geste, la box a pu changer d'avis, et réécrire un état périmé effacerait ce + /// changement. + /// + /// ## Rien à moitié écrit + /// + /// Un refus de la box laisse la configuration locale **intacte** : aucune adoption avant + /// la confirmation, et l'écriture elle-même est atomique côté box — `validateSet()` + /// refuse l'ensemble ou l'accepte en entier, il n'y a pas d'état intermédiaire. + /// + /// Refusé aussi tant qu'un **brouillon** est en cours : sinon la suppression emporterait + /// des modifications non enregistrées que personne n'a demandé d'écrire. + /// + /// \return `null` si la box a accepté, sinon un code : `PendingDraft`, `NotFound`, + /// `NoService`, ou le refus de la box tel qu'elle le nomme. + Future removeLoad(String loadId) async { + final service = _service; + if (service == null) return 'NoService'; + if (_draft != null) return 'PendingDraft'; + + // 1. LIRE — la charge utile de la box, à cet instant. + final List> frais; + try { + frais = await service.getLoadConfig(); + } catch (e) { + _saveError = e.toString(); + _saveState = LoadSaveState.rejected; + notifyListeners(); + return _saveError; + } + if (frais.isEmpty) return 'NotFound'; + + // 2. PATCHER — règle pure, éprouvée à part (`payloadWithoutLoad`). + final reste = payloadWithoutLoad(frais, loadId); + if (reste == null) return 'NotFound'; + + // 3. RÉÉCRIRE. + _expectedSignature = _signatureOfPayload(reste); + _conflict = null; + _saveError = null; + _saveState = LoadSaveState.pending; + notifyListeners(); + + final err = await service.setLoadConfig(reste); + if (err != null) { + // Rien n'a bougé : ni la box (refus en bloc), ni l'état local (aucune adoption). + _saveState = LoadSaveState.rejected; + _saveError = err; + _expectedSignature = null; + notifyListeners(); + return err; + } + + // L'acquittement ne vaut pas application : on attend `LoadConfigChanged`, avec le même + // filet de relecture que `save()`. + _confirmTimeout?.cancel(); + _confirmTimeout = Timer(const Duration(seconds: 6), () async { + if (_saveState != LoadSaveState.pending) return; + await load(); + _reconcile(); + }); + return null; + } + void _onBoxConfirmed(List> configs) { _adopt(configs); _reconcile(); diff --git a/lib/screens/energy/load_mechanism_screen.dart b/lib/screens/energy/load_mechanism_screen.dart index 18165b2..5977ad1 100644 --- a/lib/screens/energy/load_mechanism_screen.dart +++ b/lib/screens/energy/load_mechanism_screen.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../l10n/l10n_ext.dart'; +import '../../models/energy_data.dart' show ChargingMode; +import '../../models/ev_charger_live.dart'; import '../../models/load_config_entry.dart'; import '../../providers/load_config_provider.dart'; import '../../providers/load_telemetry_provider.dart'; @@ -13,6 +15,7 @@ import '../../services/nymea_service.dart'; import '../../services/rpc_schema.dart'; import '../../theme/etm_tokens.dart'; import '../../widgets/load_cards.dart'; +import '../../widgets/ev_mode_buttons.dart'; import '../../widgets/schema_gated.dart'; import '../../widgets/thing_picker.dart'; @@ -92,7 +95,8 @@ class _LoadMechanismScreenState extends State { // et des paliers — des réglages qui n'existent pas pour elle, et dont // l'écriture partirait quand même dans `SetLoadConfig`. if (e.isEvCharger) - _SectionBorne(entry: e, telemetry: telemetry) + _SectionBorne( + entry: e, telemetry: telemetry, service: service) else if (e.isRelayRouter) _SectionRelais( entry: e, @@ -401,14 +405,18 @@ class _Mecanisme extends StatelessWidget { class _SectionBorne extends StatelessWidget { final LoadConfigEntry entry; final LoadTelemetryProvider telemetry; + final NymeaService service; - const _SectionBorne({required this.entry, required this.telemetry}); + const _SectionBorne( + {required this.entry, required this.telemetry, required this.service}); @override Widget build(BuildContext context) { final muted = EtmTokens.mutedOf(context); final live = telemetry.entryFor(entry.id); final mech = live?.mechanism; + // La borne DE CET ÉCRAN, pas « la première configurée » : le banc en porte trois. + final borne = service.evChargerFor(entry.id); final lignes = []; if (mech != null) { @@ -456,10 +464,74 @@ class _SectionBorne extends StatelessWidget { style: TextStyle(fontSize: 11.5, height: 1.35, color: muted), ), ], + // ── LE SEUL RÉGLAGE d'une borne, et le seul lieu où il s'écrit ──── + // + // Il vivait sur la tuile des favoris. Il est ici depuis le 2026-08-28 : c'est + // l'écran de mise en service, celui où on découvre qu'une borne neuve est en + // ChargingModeNormal — pleine puissance, hors arbitrage. La tuile garde la + // lecture et l'avertissement, pas l'écriture : deux endroits qui écrivent + // SetChargingInfo sont deux comportements qui divergent. + if (borne != null) ...[ + const SizedBox(height: 14), + Divider(height: 1, color: EtmTokens.lineOf(context)), + const SizedBox(height: 12), + Row(children: [ + Text('Mode de recharge', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: EtmTokens.inkOf(context))), + const SizedBox(width: 8), + Expanded( + child: Text(_modeLu(borne), + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 11.5, color: muted)), + ), + ]), + const SizedBox(height: 8), + EvModeButtons( + live: borne, + onSetMode: (m) => + service.setChargingInfo(mode: m, evChargerId: borne.thingId), + ), + if (borne.isUnmanaged) ...[ + const SizedBox(height: 8), + Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Icon(Icons.flash_on_rounded, size: 15, color: EtmTokens.solar), + const SizedBox(width: 6), + Expanded( + child: Text(kEvUnmanagedNote, + style: + TextStyle(fontSize: 11.5, height: 1.35, color: muted)), + ), + ]), + ], + ] else ...[ + const SizedBox(height: 10), + Text( + 'Mode de recharge non lu : la box n\'a pas encore rendu de ChargingInfo ' + 'pour cette borne. Sans lecture, l\'écran ne propose pas d\'écriture — un ' + 'bouton allumé au hasard vaudrait moins que rien.', + style: TextStyle(fontSize: 11.5, height: 1.35, color: muted), + ), + ], ], ), ); } + + /// Le mode **que la box porte**, jamais celui que l'app vient de demander. + String _modeLu(EvChargerLive b) { + if (b.apiMode == null) return 'mode non lu'; + final ui = b.uiMode; + if (ui == null) return 'mode non reconnu (${b.apiMode})'; + final base = switch (ui) { + ChargingMode.pv => 'PV', + ChargingMode.minPv => 'Min+PV', + ChargingMode.boost => 'Boost', + }; + return b.hasDeadlineMode ? 'lu : $base + échéance' : 'lu : $base'; + } } /// Configuration **héritée** : `etmvariableload` en multipalier natif (`fixed`). diff --git a/lib/screens/favorites_screen.dart b/lib/screens/favorites_screen.dart index fd72acc..e586124 100644 --- a/lib/screens/favorites_screen.dart +++ b/lib/screens/favorites_screen.dart @@ -211,9 +211,10 @@ class _FavoriteCard extends StatelessWidget { // La tuile ne reçoit plus `EnergyData` : ses trois grandeurs de recharge // n'étaient jamais lues sur la box. Elle lit la borne, ou dit qu'il n'y a // rien à lire. - FavoriteType.evCharger => EvFavoriteTile( - live: service.evCharger, - onSetMode: (m) => service.setChargingInfo(mode: m)), + // Lecture seule depuis le 2026-08-28 : les trois modes se règlent sur l'écran + // « Configurer » de la borne, un seul lieu d'écriture. La note « hors arbitrage » + // reste ici — c'est le client qui ouvre ses favoris. + FavoriteType.evCharger => EvFavoriteTile(live: service.evCharger), FavoriteType.thingState => _ThingStateWidget(fav: fav, service: service), FavoriteType.chart => _MiniChart(history: service.historyPoints), }; diff --git a/lib/services/load_priority.dart b/lib/services/load_priority.dart index cc7890e..33ede79 100644 --- a/lib/services/load_priority.dart +++ b/lib/services/load_priority.dart @@ -129,6 +129,28 @@ List tiedByPriority(List entries) { ]; } +/// La charge utile à réécrire pour **supprimer** une charge — `null` si elle n'y est pas. +/// +/// `SetLoadConfig` remplace l'ensemble : supprimer une charge, c'est réécrire toutes les +/// autres. Elles sont donc recopiées **verbatim**, telles que la box vient de les rendre, +/// jamais reconstruites 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 — le tri est sur `priority` — alors qu'une renumérotation réécrirait le rang de +/// charges que l'utilisateur n'a pas touchées. +/// +/// `null` plutôt qu'une liste inchangée : « rien à supprimer » et « supprimé » ne doivent +/// pas se ressembler côté appelant. +List>? payloadWithoutLoad( + List> frais, String loadId) { + final reste = >[ + for (final m in frais) + if (m['id'] != loadId) Map.from(m), + ]; + return reste.length == frais.length ? null : reste; +} + /// Reconstitue les deux niveaux depuis la liste brute de `GetLoadConfig`. /// /// Le rang de domaine est l'**ordre d'apparition** dans l'ordre à plat trié par diff --git a/lib/services/nymea_service.dart b/lib/services/nymea_service.dart index 7135c4d..7d6f078 100644 --- a/lib/services/nymea_service.dart +++ b/lib/services/nymea_service.dart @@ -1259,6 +1259,9 @@ class NymeaService extends ChangeNotifier { bool deadline = false, int targetSoc = 80, DateTime? endTime, + /// Borne visée. Omis, l'écriture part sur la **première** borne configurée — ce qui + /// n'a de sens que sur une installation qui n'en a qu'une. Le banc en porte trois. + String? evChargerId, }) async { // (mode d'UI, échéance) → énumération publiée par la box. final String apiMode; @@ -1276,8 +1279,8 @@ class NymeaService extends ChangeNotifier { String? error; if (_connected && !_isSimulation) { - final evChargerId = _findEvChargerId(); - if (evChargerId == null) { + final vise = evChargerId ?? _findEvChargerId(); + if (vise == null) { _log('[Recharge] SetChargingInfo: aucune borne trouvée', force: true); error = 'NoEvCharger'; } else { @@ -1286,11 +1289,11 @@ class NymeaService extends ChangeNotifier { // retombe sur son défaut C++. Une lecture qui échoue laisse `courant` vide : // on écrit alors le strict nécessaire, ce qui n'est pas pire que l'ancien // comportement et reste honnête sur ce qu'on sait. - final courant = await getChargingInfo(evChargerId) ?? const {}; + final courant = await getChargingInfo(vise) ?? const {}; final info = { ...courant, - 'evChargerId': evChargerId, + 'evChargerId': vise, 'chargingMode': apiMode, }; // `chargingState` est en lecture seule (`r:`) au schéma : le renvoyer ferait @@ -1391,6 +1394,23 @@ class NymeaService extends ChangeNotifier { return null; } + /// Porte de test — injecte des Things et leurs classes sans transport. + /// + /// Nécessaire pour éprouver ce qui dépend d'un **état de Thing** : la borne se trouve + /// par son interface, et ses états (`pluggedIn`, `charging`, `currentPower`) se lisent + /// par le nom déclaré dans la `ThingClass`. + @visibleForTesting + void adoptThingsForTest( + List things, List classes) { + _things + ..clear() + ..addAll(things); + _thingClasses + ..clear() + ..addAll(classes); + notifyListeners(); + } + /// Porte de test — même usage que `LoadConfigProvider.adoptForTest` : éprouver /// l'affichage sur une charge utile réelle sans box. @visibleForTesting @@ -1435,8 +1455,23 @@ class NymeaService extends ChangeNotifier { /// `null` = aucune borne sur cette installation. Chaque champ de [EvChargerLive] est /// nullable pour la même raison : une borne qui ne publie pas `currentPower` ne consomme /// pas 0 W, elle ne le dit pas. Cf. LM-1009 §C1. - EvChargerLive? get evCharger { - final id = _findEvChargerId(); + EvChargerLive? get evCharger => evChargerFor(_findEvChargerId()); + + /// La borne d'identifiant donné, `thingId` **ou** id de charge `GetLoadConfig` (les + /// accolades sont réconciliées — cf. `docs/UI_data_contract.md` §9). + /// + /// Le banc en porte trois : viser « la première » depuis un écran qui parle d'une borne + /// précise afficherait — et écrirait — sur la mauvaise. + EvChargerLive? evChargerFor(String? wanted) { + if (wanted == null) return null; + final nu = wanted.replaceAll('{', '').replaceAll('}', '').toLowerCase(); + String? id; + for (final t in _things) { + if (t.id.replaceAll('{', '').replaceAll('}', '').toLowerCase() == nu) { + id = t.id; + break; + } + } if (id == null) return null; NymeaThing? thing; for (final t in _things) { diff --git a/lib/widgets/ev_favorite_tile.dart b/lib/widgets/ev_favorite_tile.dart index 4d9de49..77eca32 100644 --- a/lib/widgets/ev_favorite_tile.dart +++ b/lib/widgets/ev_favorite_tile.dart @@ -31,46 +31,27 @@ import '../theme/etm_tokens.dart'; /// interruption, et l'égalité `sessionEnergy == chargeEnergy` relevée le 2026-08-28 ne /// prouve pas encore que le second y survive. /// -/// ## L'écriture restitue le refus, ou n'existe pas +/// ## Elle ne règle plus rien — et garde quand même l'avertissement /// -/// Les trois modes s'écrivaient sur des pastilles de 7 à 10 px qui **avalaient les -/// refus** : `setChargingInfo` rend un code d'erreur, personne ne le lisait. Une commande -/// muette est pire qu'une lecture seule — l'utilisateur croit avoir réglé la borne. Les -/// boutons font désormais 44 px, montrent l'attente, et affichent le refus tel que la box -/// le nomme. -class EvFavoriteTile extends StatefulWidget { +/// Les trois boutons de mode sont partis le 2026-08-28 vers l'écran « Configurer » de la +/// borne : deux endroits qui écrivent `SetChargingInfo` sont deux comportements qui +/// divergent, et la mise en service se fait là-bas. Cette tuile est donc **en lecture +/// seule**. +/// +/// **Mais la note « hors arbitrage » reste ici.** C'est le client qui ouvre ses favoris, +/// pas l'installateur : si sa borne charge à pleine puissance hors du pilotage, il doit +/// le voir là où il regarde — même s'il doit aller ailleurs pour le corriger. +class EvFavoriteTile extends StatelessWidget { /// `null` = aucune borne déclarée sur l'installation. final EvChargerLive? live; - /// Écrit le mode et rend `null` si la box a accepté, sinon le code de refus. - final Future Function(ChargingMode) onSetMode; - - const EvFavoriteTile({super.key, required this.live, required this.onSetMode}); - - @override - State createState() => _EvFavoriteTileState(); -} - -class _EvFavoriteTileState extends State { - /// Mode en cours d'écriture — jamais affiché comme sélectionné : tant que la box n'a pas - /// répondu, l'app ne sait pas ce que porte la borne. - ChargingMode? _pending; - - Future _set(ChargingMode m) async { - setState(() => _pending = m); - final err = await widget.onSetMode(m); - if (!mounted) return; - setState(() => _pending = null); - if (err == null) return; - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text('La box a refusé le mode de recharge ($err). ' - 'Détail dans son journal.'), - )); - } + const EvFavoriteTile({super.key, required this.live}); @override Widget build(BuildContext context) { - final live = widget.live; + // Copie locale : un champ nullable ne se promeut pas après un test, et la tuile lit + // une douzaine de fois la même borne. + final live = this.live; if (live == null) { return _Muet( icon: Icons.ev_station_outlined, @@ -136,13 +117,6 @@ class _EvFavoriteTileState extends State { ], ), ), - const SizedBox(width: 8), - Column( - mainAxisSize: MainAxisSize.min, - children: [ - for (final m in ChargingMode.values) _bouton(live, m), - ], - ), ], ); } @@ -170,52 +144,6 @@ class _EvFavoriteTileState extends State { }; return live.hasDeadlineMode ? 'Mode $base + échéance' : 'Mode $base'; } - - /// 44 px de cible tactile — les pastilles précédentes faisaient 7 à 10 px, et le premier - /// passage sur appareil avait déjà trouvé deux cibles hors norme sur d'autres écrans. - Widget _bouton(EvChargerLive live, ChargingMode m) { - final (label, couleur) = switch (m) { - ChargingMode.pv => ('PV', EtmTokens.amber), - ChargingMode.minPv => ('Min+PV', EtmTokens.blue), - ChargingMode.boost => ('Boost', EtmTokens.green), - }; - // Sélection = ce que la BOX porte. Un mode non lu ne sélectionne rien : mieux vaut - // trois boutons éteints qu'un bouton allumé au hasard. - final selected = live.uiMode == m; - final busy = _pending != null; - - return Padding( - padding: const EdgeInsets.symmetric(vertical: 2), - child: SizedBox( - height: 44, - width: 76, - child: OutlinedButton( - onPressed: busy ? null : () => _set(m), - style: OutlinedButton.styleFrom( - padding: EdgeInsets.zero, - backgroundColor: - selected ? couleur.withValues(alpha: 0.14) : Colors.transparent, - side: BorderSide( - color: selected - ? couleur - : EtmTokens.lineOf(context).withValues(alpha: 0.6)), - shape: - RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), - ), - child: _pending == m - ? const SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator(strokeWidth: 2)) - : Text(label, - style: EtmTokens.sans( - size: 11, - weight: selected ? FontWeight.w700 : FontWeight.w400, - color: selected ? couleur : EtmTokens.mutedOf(context))), - ), - ), - ); - } } /// Tuile sans donnée : elle nomme ce qu'elle attend, plutôt que d'afficher un zéro. diff --git a/lib/widgets/ev_mode_buttons.dart b/lib/widgets/ev_mode_buttons.dart new file mode 100644 index 0000000..f77e884 --- /dev/null +++ b/lib/widgets/ev_mode_buttons.dart @@ -0,0 +1,119 @@ +import 'package:flutter/material.dart'; + +import '../models/energy_data.dart' show ChargingMode; +import '../models/ev_charger_live.dart'; +import '../theme/etm_tokens.dart'; + +/// Les trois modes de recharge — **le seul endroit de l'app qui écrit `SetChargingInfo`**. +/// +/// ## Un seul lieu d'écriture, délibérément +/// +/// Ces boutons ont vécu aux favoris, sur la tuile de la borne. Ils sont ici depuis le +/// 2026-08-28 : « Configurer » est l'écran de **mise en service**, celui où un installateur +/// découvre qu'une borne neuve est hors arbitrage — et deux écrans qui écrivent la même +/// chose sont deux comportements qui divergent. La tuile des favoris garde la LECTURE et +/// la note « hors arbitrage » : le client doit voir que sa borne n'est pas pilotée, même +/// là où il ne peut pas la régler. +/// +/// ## Ce qui est affiché comme sélectionné +/// +/// Le mode que la **box** porte, jamais celui que l'app vient de demander. Un mode non lu +/// n'allume rien : trois boutons éteints valent mieux qu'un allumé au hasard. Après une +/// écriture acceptée, `NymeaService` relit `GetChargingInfos` — la sélection ne bouge que +/// quand la box l'a dit. +/// +/// ## Un refus se voit +/// +/// `setChargingInfo` rend un code d'erreur ; il était avalé. Une commande muette est pire +/// qu'une lecture seule — l'utilisateur croit avoir réglé la borne. +class EvModeButtons extends StatefulWidget { + final EvChargerLive live; + + /// Écrit le mode et rend `null` si la box a accepté, sinon le code de refus. + final Future Function(ChargingMode) onSetMode; + + /// Disposition : en colonne sur une tuile étroite, en ligne sur un écran. + final Axis axis; + + const EvModeButtons({ + super.key, + required this.live, + required this.onSetMode, + this.axis = Axis.horizontal, + }); + + @override + State createState() => _EvModeButtonsState(); +} + +class _EvModeButtonsState extends State { + /// Mode en cours d'écriture — jamais affiché comme sélectionné : tant que la box n'a pas + /// répondu, l'app ne sait pas ce que porte la borne. + ChargingMode? _pending; + + Future _set(ChargingMode m) async { + setState(() => _pending = m); + final err = await widget.onSetMode(m); + if (!mounted) return; + setState(() => _pending = null); + if (err == null) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('La box a refusé le mode de recharge ($err). ' + 'Détail dans son journal.'), + )); + } + + @override + Widget build(BuildContext context) { + final boutons = [for (final m in ChargingMode.values) _bouton(m)]; + return widget.axis == Axis.horizontal + ? Row(children: [ + for (final b in boutons) ...[Expanded(child: b), const SizedBox(width: 6)] + ]..removeLast()) + : Column(mainAxisSize: MainAxisSize.min, children: boutons); + } + + /// 44 px de cible tactile — les pastilles d'origine faisaient 7 à 10 px, et écrivaient + /// pour de vrai. + Widget _bouton(ChargingMode m) { + final (label, couleur) = switch (m) { + ChargingMode.pv => ('PV', EtmTokens.amber), + ChargingMode.minPv => ('Min+PV', EtmTokens.blue), + ChargingMode.boost => ('Boost', EtmTokens.green), + }; + final selected = widget.live.uiMode == m; + final busy = _pending != null; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: SizedBox( + height: 44, + width: widget.axis == Axis.vertical ? 76 : null, + child: OutlinedButton( + onPressed: busy ? null : () => _set(m), + style: OutlinedButton.styleFrom( + padding: EdgeInsets.zero, + backgroundColor: + selected ? couleur.withValues(alpha: 0.14) : Colors.transparent, + side: BorderSide( + color: selected + ? couleur + : EtmTokens.lineOf(context).withValues(alpha: 0.6)), + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + child: _pending == m + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2)) + : Text(label, + style: EtmTokens.sans( + size: 12, + weight: selected ? FontWeight.w700 : FontWeight.w400, + color: selected ? couleur : EtmTokens.mutedOf(context))), + ), + ), + ); + } +} diff --git a/lib/widgets/load_cards.dart b/lib/widgets/load_cards.dart index d5da140..f649cae 100644 --- a/lib/widgets/load_cards.dart +++ b/lib/widgets/load_cards.dart @@ -13,6 +13,7 @@ library; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; import '../l10n/app_localizations.dart'; import '../l10n/l10n_ext.dart'; @@ -333,10 +334,12 @@ class LoadFaultBox extends StatelessWidget { // une entrée `LoadConfig` par borne détectée (`+etm23`) mais ne la retire pas // quand le Thing s'en va — constaté sur `.75` avec une ABB Terra AC remplacée // par une V2C, dont l'entrée occupait encore un rang deux jours plus tard. - if (thingAbsent) + if (thingAbsent) ...[ Text(t.faultThingMissingAction, - style: const TextStyle(fontSize: 10.5, color: Color(0xFF7F1D1D))) - else ...[ + style: const TextStyle(fontSize: 10.5, color: Color(0xFF7F1D1D))), + const SizedBox(height: 6), + _BoutonRetirer(loadId: live.loadId), + ] else ...[ Text(t.faultClearHint, style: const TextStyle(fontSize: 10, color: Color(0xFF9A5B5B))), if (message != null) ...[ @@ -366,6 +369,117 @@ class LoadFaultBox extends StatelessWidget { } } +/// **Retirer une charge dont l'appareil n'existe plus** — le seul geste destructeur. +/// +/// Il n'apparaît que sur `THING_MISSING`, et c'est délibéré : sur une charge vivante, la +/// suppression n'aurait pas de sens, le plugin recréant d'office l'entrée d'une borne +/// détectée au cycle suivant. Proposer un bouton qui se défait tout seul ferait douter de +/// la box. +/// +/// La confirmation dit ce qui va être écrit, pas seulement ce qui va disparaître : +/// `SetLoadConfig` remplace **tout** l'ensemble, donc retirer une charge réécrit les +/// autres. C'est la seule chose que l'utilisateur ne peut pas deviner de l'écran. +class _BoutonRetirer extends StatefulWidget { + final String loadId; + const _BoutonRetirer({required this.loadId}); + + @override + State<_BoutonRetirer> createState() => _BoutonRetirerState(); +} + +class _BoutonRetirerState extends State<_BoutonRetirer> { + bool _enCours = false; + + Future _retirer() async { + final provider = context.read(); + final messenger = ScaffoldMessenger.of(context); + + // Un brouillon en cours emporterait des modifications que personne n'a demandé + // d'écrire : on le dit AVANT d'ouvrir la confirmation, pas après. + if (provider.hasPendingChanges) { + messenger.showSnackBar(const SnackBar( + content: Text('Enregistrez ou annulez vos modifications en cours avant de ' + 'retirer une charge : la suppression réécrit toute la configuration.'), + )); + return; + } + + final entree = provider.entries + .where((e) => e.id == widget.loadId) + .map((e) => e.label) + .firstOrNull; + + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Retirer cette charge ?'), + content: Text( + '${entree ?? widget.loadId} sera retirée de la configuration de la box.\n\n' + 'L\'appareil n\'existe plus dans nymea : rien ne sera recréé. Si vous le ' + 'réinstallez plus tard, une nouvelle entrée apparaîtra d\'elle-même pour une ' + 'borne détectée.\n\n' + 'La box réécrit TOUTE la configuration en une fois : les autres charges sont ' + 'réenvoyées à l\'identique, telles qu\'elle les porte en ce moment.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Annuler')), + FilledButton( + style: FilledButton.styleFrom(backgroundColor: const Color(0xFFC62828)), + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Retirer'), + ), + ], + ), + ); + if (ok != true || !mounted) return; + + setState(() => _enCours = true); + final err = await provider.removeLoad(widget.loadId); + if (!mounted) return; + setState(() => _enCours = false); + + messenger.showSnackBar(SnackBar( + content: Text(err == null + // « Demandée » et non « faite » : l'acquittement du RPC ne vaut pas application, + // c'est `LoadConfigChanged` qui tranche — et la liste se mettra à jour seule. + ? 'Suppression demandée. La liste se met à jour dès que la box confirme.' + : _messageErreur(err)), + )); + } + + String _messageErreur(String code) => switch (code) { + 'PendingDraft' => + 'Modifications en cours : enregistrez-les ou annulez-les d\'abord.', + 'NotFound' => + 'Cette charge n\'est plus dans la configuration de la box — rien à retirer.', + 'NoService' => 'Aucune connexion à la box.', + _ => 'La box a refusé la suppression ($code). Rien n\'a été modifié.', + }; + + @override + Widget build(BuildContext context) { + return Align( + alignment: Alignment.centerRight, + child: OutlinedButton.icon( + icon: _enCours + ? const SizedBox( + width: 12, height: 12, + child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.delete_outline_rounded, size: 16), + label: const Text('Retirer cette charge'), + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xFFC62828), + side: const BorderSide(color: Color(0xFFC62828)), + minimumSize: const Size(0, 44), + ), + onPressed: _enCours ? null : _retirer, + ), + ); + } +} + // ══════════════════════════════════════════════════════════════════════════════ // En-tête d'arbitrage — repris de l'écran « Ordre de service » à sa disparition. // diff --git a/test/ev_favorite_tile_test.dart b/test/ev_favorite_tile_test.dart index 3081bad..8f4d4f1 100644 --- a/test/ev_favorite_tile_test.dart +++ b/test/ev_favorite_tile_test.dart @@ -6,24 +6,13 @@ /// 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 Function(ChargingMode)? onSetMode, -}) => - MaterialApp( - home: Scaffold( - body: EvFavoriteTile( - live: live, - onSetMode: onSetMode ?? (_) async => null, - ), - ), - ); +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`. @@ -137,55 +126,23 @@ void main() { }); }); - 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', + 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 { - 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. + // Deux écrans qui écrivent SetChargingInfo sont deux comportements qui divergent. 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)); + 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); }); }); } diff --git a/test/ev_mode_buttons_test.dart b/test/ev_mode_buttons_test.dart new file mode 100644 index 0000000..b020495 --- /dev/null +++ b/test/ev_mode_buttons_test.dart @@ -0,0 +1,110 @@ +/// 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 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( + find.widgetWithText(OutlinedButton, 'Boost')); + final pv = + tester.widget(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(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'); + } + }); +} diff --git a/test/evcharger_config_test.dart b/test/evcharger_config_test.dart index 7b71ea3..8e28efb 100644 --- a/test/evcharger_config_test.dart +++ b/test/evcharger_config_test.dart @@ -37,6 +37,16 @@ List _banc() { ]; } +/// Les charges utiles BRUTES du banc — pas les entrées typées : c'est justement ce que la +/// suppression doit préserver. +List> _bancRaw() { + final raw = File('test/fixtures/loadconfig_hems75_v2c.json').readAsStringSync(); + return ((jsonDecode(raw) as Map)['loadConfigs'] as List) + .whereType() + .map((m) => Map.from(m)) + .toList(); +} + void main() { group('3g-2 — les bornes ont une entrée de configuration', () { test('le dump du banc porte quatre charges, dont deux bornes', () { @@ -132,6 +142,47 @@ void main() { }); }); + group('supprimer une charge — le seul geste destructeur de l\'app', () { + test('les AUTRES charges sont recopiées verbatim, champ par champ', () { + // `SetLoadConfig` remplace l'ensemble : supprimer une charge, c'est réécrire toutes + // les autres. Les reconstruire depuis le modèle typé perdrait, en silence et pour + // toutes, un champ qu'une box plus récente porterait. + final frais = _bancRaw(); + final reste = payloadWithoutLoad(frais, 'chauffe-eau')!; + + expect(reste.length, frais.length - 1); + expect(reste.any((m) => m['id'] == 'chauffe-eau'), isFalse); + for (final avant in frais.where((m) => m['id'] != 'chauffe-eau')) { + final apres = reste.firstWhere((m) => m['id'] == avant['id']); + for (final k in avant.keys) { + expect(apres[k], avant[k], reason: 'clé $k altérée sur ${avant['id']}'); + } + expect(apres.keys.length, avant.keys.length, + reason: 'aucune clé ajoutée sur ${avant['id']}'); + } + }); + + test('les rangs ne sont PAS renumérotés — le trou est sans effet', () { + // Renuméroter réécrirait le rang de charges que personne n'a touchées ; le tri est + // sur `priority`, un trou ne change l'ordre de personne. + final frais = _bancRaw(); + final avant = { + for (final m in frais) + if (m['id'] != 'pac-terrain') m['id']: m['priority'] + }; + final reste = payloadWithoutLoad(frais, 'pac-terrain')!; + for (final m in reste) { + expect(m['priority'], avant[m['id']]); + } + }); + + test('une charge absente rend NULL, pas la liste inchangée', () { + // « Rien à supprimer » et « supprimé » ne doivent pas se ressembler côté appelant : + // une liste inchangée serait réécrite pour rien, sur la foi d'un succès. + expect(payloadWithoutLoad(_bancRaw(), 'charge-qui-nexiste-pas'), isNull); + }); + }); + group('3g-2 — le rang par défaut d\'une borne est un ARTEFACT', () { test('trois charges du banc partagent le rang 1', () { // À la création, le plugin donne « le plus petit rang existant moins un, borné à diff --git a/test/load_mechanism_ev_test.dart b/test/load_mechanism_ev_test.dart index 88be7f9..6cd9e28 100644 --- a/test/load_mechanism_ev_test.dart +++ b/test/load_mechanism_ev_test.dart @@ -23,6 +23,7 @@ import 'package:etm_powersync_app/providers/load_config_provider.dart'; import 'package:etm_powersync_app/providers/load_telemetry_provider.dart'; import 'package:etm_powersync_app/providers/schema_provider.dart'; import 'package:etm_powersync_app/screens/energy/load_mechanism_screen.dart'; +import 'package:etm_powersync_app/models/nymea_models.dart'; import 'package:etm_powersync_app/services/nymea_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -38,7 +39,67 @@ List> _banc() { const kV2c = '{4d1adafd-cafc-4813-93b1-ca8933ce71ab}'; -Widget _harness(String loadId, {List>? config}) => +/// La V2C telle que `.75` la publie : classe `trydan` portant l'interface `evcharger`, et +/// les trois états que la tuile et l'écran lisent. +NymeaService _serviceAvecBorne({String mode = 'ChargingModeNormal'}) { + const classId = 'cls-trydan'; + final svc = NymeaService() + ..adoptThingsForTest( + [ + NymeaThing( + id: '4d1adafd-cafc-4813-93b1-ca8933ce71ab', + name: 'V2C Trydan', + thingClassId: classId, + setupStatus: 'ThingSetupStatusComplete', + paramValues: const [], + states: const [ + NymeaStateValue(stateTypeId: 'st-plugged', value: false), + NymeaStateValue(stateTypeId: 'st-charging', value: false), + NymeaStateValue(stateTypeId: 'st-power', value: 0), + ], + ), + ], + [ + const NymeaThingClass( + id: classId, + name: 'trydan', + displayName: 'Trydan', + interfaces: ['evcharger'], + stateTypes: [ + NymeaStateType( + id: 'st-plugged', + name: 'pluggedIn', + displayName: 'Branché', + type: 'Bool', + unit: ''), + NymeaStateType( + id: 'st-charging', + name: 'charging', + displayName: 'En charge', + type: 'Bool', + unit: ''), + NymeaStateType( + id: 'st-power', + name: 'currentPower', + displayName: 'Puissance', + type: 'Double', + unit: 'W'), + ], + ), + ], + ); + svc.adoptChargingInfosForTest([ + { + 'evChargerId': '4d1adafd-cafc-4813-93b1-ca8933ce71ab', + 'chargingMode': mode, + 'chargingState': 'ChargingStateIdle', + }, + ]); + return svc; +} + +Widget _harness(String loadId, + {List>? config, NymeaService? service}) => MultiProvider( providers: [ ChangeNotifierProvider.value( @@ -47,7 +108,7 @@ Widget _harness(String loadId, {List>? config}) => value: LoadTelemetryProvider() ..adoptForTest({'degradedMode': false, 'loads': const []})), ChangeNotifierProvider.value(value: SchemaProvider()), - ChangeNotifierProvider.value(value: NymeaService()), + ChangeNotifierProvider.value(value: service ?? NymeaService()), ], // Les délégués de localisation sont indispensables : `_Identite` lit `context.l10n` // dès sa construction, et sans eux l'écran explose avant d'afficher quoi que ce soit. @@ -126,4 +187,39 @@ void main() { expect(find.textContaining('tableau de bord'), findsNothing); expect(find.textContaining('tuile « Borne EV » des favoris'), findsOneWidget); }); + + testWidgets('les trois modes de recharge sont SUR CET ÉCRAN', (tester) async { + // Ils vivaient aux favoris ; « Configurer » est l'écran de mise en service. + await _poser(tester, + _harness(kV2c, service: _serviceAvecBorne())); + + expect(find.widgetWithText(OutlinedButton, 'PV'), findsOneWidget); + expect(find.widgetWithText(OutlinedButton, 'Min+PV'), findsOneWidget); + expect(find.widgetWithText(OutlinedButton, 'Boost'), findsOneWidget); + // Le mode LU est affiché à côté, et c'est celui de la box. + expect(find.textContaining('lu : Boost'), findsOneWidget); + // Et l'avertissement d'une borne neuve, là où on la met en service. + expect(find.textContaining('hors arbitrage'), findsOneWidget); + }); + + testWidgets('sans ChargingInfo lue, aucun bouton — on ne devine pas un mode', + (tester) async { + // Service sans borne connue : l'écran doit le DIRE, pas allumer un bouton au hasard. + await _poser(tester, _harness(kV2c)); + + expect(find.widgetWithText(OutlinedButton, 'PV'), findsNothing); + expect(find.textContaining('Mode de recharge non lu'), findsOneWidget); + }); + + testWidgets('un mode éco fait taire l\'avertissement, les boutons restent', + (tester) async { + await _poser( + tester, + _harness(kV2c, + service: _serviceAvecBorne(mode: 'ChargingModeEco'))); + + expect(find.textContaining('hors arbitrage'), findsNothing); + expect(find.widgetWithText(OutlinedButton, 'PV'), findsOneWidget); + expect(find.textContaining('lu : PV'), findsOneWidget); + }); } diff --git a/test/merged_config_screen_test.dart b/test/merged_config_screen_test.dart index 5cbe678..a671ece 100644 --- a/test/merged_config_screen_test.dart +++ b/test/merged_config_screen_test.dart @@ -229,6 +229,57 @@ void main() { }); }); + group('retirer une charge — seulement quand l\'appareil a disparu', () { + List> v2cConfig() => + (_fixture('loadconfig_hems75_v2c.json')['loadConfigs'] as List) + .cast>(); + + LoadTelemetryProvider tel() => LoadTelemetryProvider() + ..adoptForTest(_fixture('loadtelemetry_hems75_thingmissing.json')['telemetry'] + as Map); + + testWidgets('aucune charge SAINE ne propose la suppression', (tester) async { + // Sur une charge vivante le geste n'aurait pas de sens : le plugin recrée d'office + // l'entrée d'une borne détectée au cycle suivant, et un bouton qui se défait tout + // seul fait douter de la box. + final config = LoadConfigProvider()..adoptForTest(v2cConfig()); + + await tester.pumpWidget(_harness(config: config, telemetry: tel())); + await tester.pump(); + + // Une seule charge est en défaut d'appareil sur cette trame : un seul bouton. + expect(find.text('Retirer cette charge'), findsOneWidget); + }); + + testWidgets('la confirmation annonce que TOUTE la configuration est réécrite', + (tester) async { + // C'est la seule chose que l'utilisateur ne peut pas deviner de l'écran : + // `SetLoadConfig` remplace l'ensemble, donc retirer une charge réécrit les autres. + final config = LoadConfigProvider()..adoptForTest(v2cConfig()); + + await tester.pumpWidget(_harness(config: config, telemetry: tel())); + await tester.pump(); + + // Le bouton vit en bas d'une liste de cinq cartes : sans `ensureVisible`, la touche + // tombe hors écran et ne fait rien — en silence. + await tester.ensureVisible(find.text('Retirer cette charge')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Retirer cette charge')); + await tester.pumpAndSettle(); + + expect(find.text('Retirer cette charge ?'), findsOneWidget); + expect(find.textContaining('réécrit TOUTE la configuration'), findsOneWidget); + // Le nom est dans la confirmation — et aussi sur la carte derrière, d'où `findsN`. + expect(find.textContaining('Terra AC'), findsNWidgets(2)); + + // Annuler ne touche à rien. + await tester.tap(find.text('Annuler')); + await tester.pumpAndSettle(); + expect(config.entries.length, 5); + expect(find.text('Retirer cette charge ?'), findsNothing); + }); + }); + // ─────────────────────────────────────────────────────────────────────────── group('une borne à pleine puissance n\'est pas arbitrée — et l\'écran le nomme', () { List> v2cConfig() => @@ -381,6 +432,8 @@ void main() { expect(find.textContaining('Réinstallez-le dans nymea'), findsOneWidget); // Et le libellé ne doit pas se lire « votre configuration est cassée ». expect(find.textContaining('n\'existe plus dans nymea'), findsOneWidget); + // Le geste qui, lui, résout le cas : retirer l'entrée orpheline. + expect(find.text('Retirer cette charge'), findsOneWidget); // Les deux autres bornes du banc sont ABSENTES de la télémétrie captée — aucun // véhicule branché (`+etm24`). Elles restent affichées comme charges configurées, // et aucune ne porte de défaut : « hors arbitrage » n'est pas « en panne ».