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
This commit is contained in:
Patrick Schurig 2026-08-28 14:36:50 +02:00
parent 95495a84a1
commit 51a1ab929c
13 changed files with 800 additions and 160 deletions

View File

@ -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<String?> 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<Map<String, dynamic>> 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<Map<String, dynamic>> configs) {
_adopt(configs);
_reconcile();

View File

@ -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<LoadMechanismScreen> {
// 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 = <String>[];
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`).

View File

@ -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),
};

View File

@ -129,6 +129,28 @@ List<LoadConfigEntry> tiedByPriority(List<LoadConfigEntry> 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<Map<String, dynamic>>? payloadWithoutLoad(
List<Map<String, dynamic>> frais, String loadId) {
final reste = <Map<String, dynamic>>[
for (final m in frais)
if (m['id'] != loadId) Map<String, dynamic>.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

View File

@ -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 = <String, dynamic>{
...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<NymeaThing> things, List<NymeaThingClass> 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) {

View File

@ -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<String?> Function(ChargingMode) onSetMode;
const EvFavoriteTile({super.key, required this.live, required this.onSetMode});
@override
State<EvFavoriteTile> createState() => _EvFavoriteTileState();
}
class _EvFavoriteTileState extends State<EvFavoriteTile> {
/// 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<void> _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<EvFavoriteTile> {
],
),
),
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<EvFavoriteTile> {
};
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.

View File

@ -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<String?> 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<EvModeButtons> createState() => _EvModeButtonsState();
}
class _EvModeButtonsState extends State<EvModeButtons> {
/// 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<void> _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))),
),
),
);
}
}

View File

@ -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<void> _retirer() async {
final provider = context.read<LoadConfigProvider>();
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<bool>(
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.
//

View File

@ -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<String?> 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);
});
});
}

View File

@ -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<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');
}
});
}

View File

@ -37,6 +37,16 @@ List<LoadConfigEntry> _banc() {
];
}
/// Les charges utiles BRUTES du banc — pas les entrées typées : c'est justement ce que la
/// suppression doit préserver.
List<Map<String, dynamic>> _bancRaw() {
final raw = File('test/fixtures/loadconfig_hems75_v2c.json').readAsStringSync();
return ((jsonDecode(raw) as Map<String, dynamic>)['loadConfigs'] as List)
.whereType<Map>()
.map((m) => Map<String, dynamic>.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é à

View File

@ -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<Map<String, dynamic>> _banc() {
const kV2c = '{4d1adafd-cafc-4813-93b1-ca8933ce71ab}';
Widget _harness(String loadId, {List<Map<String, dynamic>>? 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<Map<String, dynamic>>? config, NymeaService? service}) =>
MultiProvider(
providers: [
ChangeNotifierProvider.value(
@ -47,7 +108,7 @@ Widget _harness(String loadId, {List<Map<String, dynamic>>? 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);
});
}

View File

@ -229,6 +229,57 @@ void main() {
});
});
group('retirer une charge — seulement quand l\'appareil a disparu', () {
List<Map<String, dynamic>> v2cConfig() =>
(_fixture('loadconfig_hems75_v2c.json')['loadConfigs'] as List)
.cast<Map<String, dynamic>>();
LoadTelemetryProvider tel() => LoadTelemetryProvider()
..adoptForTest(_fixture('loadtelemetry_hems75_thingmissing.json')['telemetry']
as Map<String, dynamic>);
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<Map<String, dynamic>> 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 ».