diff --git a/TODO.md b/TODO.md index 9597daa..2627c05 100644 --- a/TODO.md +++ b/TODO.md @@ -2,6 +2,15 @@ ## Ouvert au 2026-08-27 +- [ ] ⚠️ **Défaut moteur en cours de correction — ne pas élargir le bouton de suppression.** + Supprimer l'entrée d'une borne dont le **Thing existe encore** ne la recrée pas : elle + reste **arbitrée**, absente de `GetLoadConfig`, à un rang non réglable. C'est le trou + « arbitrée mais hors configuration » que `+etm23` avait fermé, rouvert par le geste de + suppression de l'app. Le bouton **réservé aux `THING_MISSING`** protège déjà de ce cas — + c'est la raison de fond de cette restriction, en plus de celle qu'on lui connaissait. + Ne pas l'étendre tant que le correctif moteur n'est pas déployé. + + - [ ] 🔜 **`1.15.2+etm27` — trois nouveautés, dont une déjà couverte.** Déploiement annoncé pour demain matin. - [x] ~~**Refus en bloc sur deux rangs identiques.**~~ **Couvert le 2026-08-28, avant le diff --git a/lib/providers/load_config_provider.dart b/lib/providers/load_config_provider.dart index 79fb602..65f0f2b 100644 --- a/lib/providers/load_config_provider.dart +++ b/lib/providers/load_config_provider.dart @@ -118,10 +118,16 @@ class LoadConfigProvider extends ChangeNotifier { // ── Édition (brouillon, aucune écriture) ─────────────────────────────────── /// Déplace une charge à l'intérieur de son domaine. - void reorderWithinDomain(String domain, int oldIndex, int newIndex) { + /// Déplace une charge à l'intérieur de **sa plage**, désignée par son index. + /// + /// ⚠️ Elle était désignée par son **domaine** jusqu'au 2026-08-29. Depuis qu'un groupe + /// est une plage contiguë, un même domaine peut en occuper plusieurs — `heating` séparant + /// deux plages `ev`, par exemple — et une clé de domaine désignerait alors la première + /// venue. L'index est le seul identifiant exact d'une plage. + void reorderWithinGroup(int groupIndex, int oldIndex, int newIndex) { final current = [...groups]; - final gi = current.indexWhere((g) => g.domain == domain); - if (gi < 0) return; + final gi = groupIndex; + if (gi < 0 || gi >= current.length) return; final loads = [...current[gi].loads]; if (oldIndex < 0 || oldIndex >= loads.length) return; if (newIndex > oldIndex) newIndex--; @@ -132,6 +138,24 @@ class LoadConfigProvider extends ChangeNotifier { notifyListeners(); } + /// **Regrouper par domaine, à la demande** — jamais à l'enregistrement. + /// + /// Rassemble les plages d'un même domaine à la position de leur première apparition, et + /// pose le résultat en **brouillon** : l'installateur voit le nouvel ordre, peut + /// l'annuler, et rien ne part avant `save()`. C'est ce que l'enregistrement faisait en + /// douce jusqu'au 2026-08-29 — un regroupement subi est une machine qui réordonne ce + /// qu'un humain a décidé. + void groupByDomainNow() { + final regroupe = groupContiguous(groups); + // Rien à faire si l'ordre est déjà groupé : poser un brouillon identique allumerait la + // barre d'enregistrement pour une écriture sans effet. + final avant = [for (final g in groups) ...g.loads.map((e) => e.id)]; + final apres = [for (final g in regroupe) ...g.loads.map((e) => e.id)]; + if (avant.join('|') == apres.join('|')) return; + _draft = regroupe; + notifyListeners(); + } + /// Déplace un domaine entier dans l'ordre des domaines. void reorderDomains(int oldIndex, int newIndex) { final current = [...groups]; @@ -169,7 +193,11 @@ class LoadConfigProvider extends ChangeNotifier { // La charge utile repart de la map d'origine : seule la clé `domain` est réécrite. final patched = LoadConfigEntry(moved.patched(domain: domain)); - final gi = current.indexWhere((g) => g.domain == domain); + // La DERNIÈRE plage du domaine, pas la première : un domaine peut en occuper + // plusieurs depuis que les groupes sont des plages contiguës, et remonter la charge + // dans la première la ferait sauter des rangs que personne n'a touchés. La dernière + // est la plus proche de « à la fin de ce domaine », qui était l'intention d'origine. + final gi = current.lastIndexWhere((g) => g.domain == domain); if (gi >= 0) { current[gi] = current[gi].copyWith(loads: [...current[gi].loads, patched]); } else { diff --git a/lib/services/load_priority.dart b/lib/services/load_priority.dart index f260a74..3dd47e2 100644 --- a/lib/services/load_priority.dart +++ b/lib/services/load_priority.dart @@ -129,49 +129,6 @@ List tiedByPriority(List entries) { ]; } -/// Retire `rankOrigin: "auto"` de tout ce qui part vers la box. -/// -/// ## Pourquoi l'app ne peut PAS renvoyer ce qu'elle vient de lire -/// -/// `"auto"` est la marque que le moteur pose lui-même sur une entrée qu'il a créée d'office. -/// **Un client qui l'écrit est refusé** — `EnergyErrorInvalidParameter`, l'ensemble entier -/// rejeté, rien de modifié (vérifié sur `.75` en `1.15.2+etm27`). Seuls l'effacement et -/// `"user"` sont permis. -/// -/// Or les deux chemins d'écriture de l'app recopient les charges **verbatim** — c'est leur -/// règle, et elle est bonne : réécrire depuis le modèle typé perdrait un champ qu'une box -/// plus récente porterait. Mais recopier verbatim une entrée marquée `"auto"`, c'est -/// écrire `"auto"`. Un simple renommage, ou la suppression d'une charge orpheline, serait -/// donc **refusé en bloc dès qu'une borne auto-provisionnée figure dans l'installation** — -/// et le motif ne franchit pas la frontière RPC. -/// -/// ## Ce que ça coûte, et pourquoi c'est dit -/// -/// Le champ part vide, donc la marque du moteur est **effacée** sur les charges concernées -/// — y compris celles que le geste ne visait pas. Ce n'est pas neutre : le badge « rang -/// posé par la box » disparaîtra d'une borne que personne n'a pourtant classée. -/// -/// C'est le moindre mal entre trois options, aucune bonne : refuser l'écriture (une -/// installation avec une borne neuve deviendrait non modifiable), renvoyer `"auto"` (refus -/// systématique), ou effacer une marque informative. -/// -/// **Contournement EN SURSIS.** Le moteur porte déjà l'exception demandée — -/// `reconcileRankOrigin()` accepte `"auto"` quand le persisté le porte déjà, pour ne pas -/// rompre la neutralité `Get` → `Set`. Le refus mesuré le 2026-08-29 côté app portait un -/// écho **périmé** : la marque avait été consommée entre la lecture et l'écriture, et -/// refuser un écho périmé est le bon comportement. -/// -/// Dès que l'écho *inchangé* sur une marque *fraîche* sera vérifié, **cette fonction -/// disparaît** — et la marque survivra alors aux gestes qui ne la concernent pas, ce qui -/// est le comportement voulu. Elle ne reste que le temps de cette vérification. -/// -/// Le motif général — *un interdit d'écriture doit se prononcer sur l'écho inchangé* — est -/// écrit en `docs/UI_data_contract.md` §10, avec le contrôle à faire une fois par règle. -List> sansRankOriginAuto(List> payload) => [ - for (final m in payload) - if (m['rankOrigin'] == 'auto') {...m, 'rankOrigin': ''} else m, - ]; - /// 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 @@ -267,39 +224,74 @@ GroupedOrder groupByDomain(List entries) { return c != 0 ? c : a.id.compareTo(b.id); }); - final order = []; - final buckets = >{}; + // ── Un groupe est une PLAGE CONTIGUË, pas un seau par domaine ───────────── + // + // Le regroupement par seau rassemblait les charges d'un même domaine où qu'elles + // soient dans l'ordre. L'affichage ne pouvait donc pas représenter un ordre entrelacé — + // et comme l'enregistrement réaplatit ce qui est affiché, **tout enregistrement + // regroupait**, déplaçant des charges que personne n'avait touchées. + // + // Le moteur a tranché le 2026-08-29, et l'argument vaut d'être gardé ici : **grouper à + // l'affichage oui, à l'écriture non**. Un regroupement appliqué à l'enregistrement est + // une machine qui réordonne ce qu'un humain a décidé — exactement ce que LM-1209 + // interdit au moteur. Et la contiguïté n'a jamais été un invariant : le waterfall trie + // sur le **rang seul**, `domain` n'est pas lu par l'arbitre. + // + // En découpant par plages, l'affichage épouse l'ordre réel : deux plages `ev` séparées + // par `heating` s'affichent comme deux blocs, et réaplatir rend exactement ce qu'on a + // lu. Le regroupement devient un **geste explicite** (`groupContiguous`), jamais un + // effet de bord. + final groups = []; for (final e in flat) { final key = e.domain ?? kUnclassifiedDomain; - if (!buckets.containsKey(key)) { - buckets[key] = []; - order.add(key); + if (groups.isNotEmpty && groups.last.domain == key) { + groups.last.loads.add(e); + } else { + groups.add(DomainGroup(domain: key, loads: [e])); } - buckets[key]!.add(e); } - // Contiguïté : les indices d'un même domaine doivent former une plage continue. - final interleaved = []; - for (final key in order) { - final idx = []; - for (var i = 0; i < flat.length; i++) { - if ((flat[i].domain ?? kUnclassifiedDomain) == key) idx.add(i); - } - if (idx.isNotEmpty && (idx.last - idx.first + 1) != idx.length) { - interleaved.add(key); - } + // Un domaine présent dans plusieurs plages : ce n'est **pas un défaut**, seulement un + // ordre non groupé. L'information reste publiée — un écran peut vouloir proposer le + // regroupement — mais elle ne s'annonce plus comme une anomalie à réparer. + final vus = {}; + for (final g in groups) { + vus[g.domain] = (vus[g.domain] ?? 0) + 1; } + final interleaved = [ + for (final e in vus.entries) + if (e.value > 1) e.key + ]; return GroupedOrder( - groups: [ - for (final key in order) DomainGroup(domain: key, loads: buckets[key]!), - ], + groups: groups, flatOrder: flat, isContiguous: interleaved.isEmpty, interleavedDomains: interleaved, ); } +/// **Regrouper par domaine — un geste, jamais un effet de bord.** +/// +/// Rassemble les plages d'un même domaine à la position de leur **première** apparition, +/// et rend le nouvel ordre à plat. C'est ce que l'enregistrement faisait en douce jusqu'au +/// 2026-08-29 ; c'est désormais quelque chose que l'installateur demande, voit, et peut +/// annuler avant d'écrire. +List groupContiguous(List groups) { + final ordre = []; + final parDomaine = >{}; + for (final g in groups) { + if (!parDomaine.containsKey(g.domain)) { + parDomaine[g.domain] = []; + ordre.add(g.domain); + } + parDomaine[g.domain]!.addAll(g.loads); + } + return [ + for (final d in ordre) DomainGroup(domain: d, loads: parDomaine[d]!), + ]; +} + /// Réaplatit les deux niveaux en une suite d'entiers `1..N`, et renvoie les charges /// utiles **patchées** prêtes pour `SetLoadConfig`. /// diff --git a/lib/services/nymea_service.dart b/lib/services/nymea_service.dart index 796dad5..bd859c5 100644 --- a/lib/services/nymea_service.dart +++ b/lib/services/nymea_service.dart @@ -11,8 +11,7 @@ import '../models/energy_data.dart'; // EnergyData, HistoryPoint, PowerBalanc import '../models/ev_charger_live.dart'; import '../models/modbus_models.dart'; // ModbusRtuMaster, SerialPort import '../models/nymea_models.dart'; -import 'energy_ratios.dart'; -import 'load_priority.dart'; // seam interim ratios (§2.1) +import 'energy_ratios.dart'; // seam interim ratios (§2.1) // ── Protocole de connexion ───────────────────────────────────────────────────── enum NymeaProtocol { @@ -1609,13 +1608,16 @@ class NymeaService extends ChangeNotifier { /// `NymeaEnergy.LoadConfigChanged`, émise après toute persistance réussie. /// /// \return `null` si la box a accepté, sinon le code d'erreur qu'elle a renvoyé. - Future setLoadConfig(List> rawPayloads) async { - // ⚠️ UNE SEULE PORTE, et c'est ici : `rankOrigin: "auto"` est refusé à tout client - // (`EnergyErrorInvalidParameter`, ensemble entier rejeté). Les chemins d'écriture - // recopient les charges verbatim — donc ils recopieraient aussi cette marque. Le - // nettoyage est posé au point de sortie plutôt que sur chaque chemin : un chemin - // ajouté demain hériterait sinon du refus, sans que rien ne l'annonce. - final payloads = sansRankOriginAuto(rawPayloads); + Future setLoadConfig(List> payloads) async { + // Le nettoyage de `rankOrigin: "auto"` qui vivait ici est RETIRÉ le 2026-08-29 : + // l'écho verbatim inchangé passe (`reconcileRankOrigin()` accepte `"auto"` quand le + // persisté le porte déjà, pour ne pas rompre la neutralité Get → Set). La marque + // survit donc désormais aux gestes qui ne la concernent pas, et le badge ne s'éteint + // plus sur une borne que personne n'a classée. + // + // Ce qui reste vrai, et que le §10 du contrat de données garde : un écho **périmé** — + // `"auto"` renvoyé alors que le persisté ne le porte plus — est refusé, à juste titre. + // C'est ce cas-là que l'app avait mesuré en croyant mesurer l'autre. if (_isSimulation || !_connected) { _log('[LoadConfig] sim/déconnecté — SetLoadConfig ignoré', force: true); return 'Disconnected'; diff --git a/lib/widgets/controllable_loads_section.dart b/lib/widgets/controllable_loads_section.dart index d1704ec..de98882 100644 --- a/lib/widgets/controllable_loads_section.dart +++ b/lib/widgets/controllable_loads_section.dart @@ -58,10 +58,11 @@ class ControllableLoadsSection extends StatefulWidget { } class _ControllableLoadsSectionState extends State { - /// Domaines repliés, par leur code. + /// Plages repliées, par l'**identifiant de leur première charge**. /// - /// Clé par **code de domaine** et non par index : un domaine qui se déplace emporte - /// son repli, ce qu'un index ne saurait pas faire — il replierait le voisin. + /// Ni par index — il replierait le voisin dès qu'une plage se déplace — ni par code de + /// domaine, depuis que les groupes sont des plages contiguës : deux plages d'un même + /// domaine partageraient la clé et se replieraient ensemble. /// /// **Non persisté**, délibérément : c'est une commodité de lecture, pas une /// configuration. La rendre durable ferait retrouver un écran à moitié caché des @@ -108,17 +109,24 @@ class _ControllableLoadsSectionState extends State { itemCount: groups.length, onReorder: provider.reorderDomains, itemBuilder: (context, i) => _DomainBlock( - key: ValueKey('domain-${groups[i].domain}'), + // ⚠️ La clé porte l'id de la PREMIÈRE charge de la plage, pas le domaine : + // deux plages d'un même domaine partageraient sinon la même clé, et une liste + // réordonnable à clés dupliquées casse — l'assertion tombe dans les + // sémantiques, loin de la cause. + key: ValueKey('plage-${groups[i].loads.first.id}'), group: groups[i], index: i, total: groups.length, provider: provider, telemetry: telemetry, onConfigure: widget.onConfigure, - replie: _replies.contains(groups[i].domain), + // Le repli suit la PLAGE, pas le domaine : deux plages d'un même domaine se + // replieraient ensemble, et l'une des deux se fermerait sans qu'on l'ait + // touchée. + replie: _replies.contains(groups[i].loads.first.id), onReplier: () => setState(() { - final d = groups[i].domain; - _replies.contains(d) ? _replies.remove(d) : _replies.add(d); + final k = groups[i].loads.first.id; + _replies.contains(k) ? _replies.remove(k) : _replies.add(k); }), ), ), @@ -146,24 +154,25 @@ class _ControllableLoadsSectionState extends State { } } -/// **L'ordre en place n'est PAS groupé par domaine — et l'enregistrer le changerait.** +/// **L'ordre n'est pas groupé par domaine — et ce n'est pas un défaut.** /// -/// `GroupedOrder` calcule cette contiguïté depuis le premier jour, et le provider -/// l'expose (`isNotGrouped`). Rien ne l'affichait : le cas ne s'était jamais produit sur -/// le banc, chaque domaine y ayant ses charges d'un seul tenant. +/// ## Elle a cessé d'être un avertissement le 2026-08-29 /// -/// Il s'est produit le 2026-08-28, à l'arrivée de la V2C. Créée d'office au rang 1 — que -/// le chauffe-eau occupait déjà — elle a donné l'ordre à plat `ecs · ev · heating · ev · -/// ev` : le domaine `ev` est coupé en deux par le chauffage. +/// Elle annonçait un ordre « coupé en deux » et prévenait que l'enregistrement le +/// regrouperait. Les deux moitiés de cette phrase ont changé : /// -/// **Pourquoi il faut le dire, et pas seulement le savoir.** L'écran groupe les charges -/// par domaine pour les afficher. Sur un ordre entrelacé, ce regroupement ne correspond -/// plus à l'ordre réel de la box, et le premier enregistrement réaplatit en groupant — -/// donc **change l'arbitrage** sans que personne ne l'ait demandé. Mesuré sur la -/// configuration du banc : la PAC passerait du rang 2 au rang 5. +/// - **l'entrelacement n'est pas une incohérence.** La contiguïté n'a jamais été un +/// invariant : le waterfall trie sur le **rang seul**, et `domain` n'est pas lu par +/// l'arbitre. Depuis `+etm27`, une borne détectée est même rangée en queue de liste, ce +/// qui produit un ordre non groupé sur une installation parfaitement saine ; +/// - **l'enregistrement ne regroupe plus.** Un groupe est désormais une **plage +/// contiguë** : l'affichage épouse l'ordre réel, et réaplatir rend exactement ce qui a +/// été lu. Un regroupement subi serait une machine qui réordonne ce qu'un humain a +/// décidé — ce que LM-1209 interdit au moteur, et qu'on ne va pas faire à sa place. /// -/// La note ne bloque rien : elle nomme l'écart et annonce ce que l'enregistrement fera. -/// Décider reste à l'installateur — mais en connaissance de cause. +/// Ce qui reste : une **information**, et un **geste** pour qui veut grouper — un bouton, +/// dont l'effet se voit en brouillon et s'annule avant écriture. L'annoncer comme un défaut +/// pousserait à réparer un ordre que personne n'a cassé. class _NoteOrdreEntrelace extends StatelessWidget { final LoadConfigProvider provider; @@ -182,22 +191,37 @@ class _NoteOrdreEntrelace extends StatelessWidget { width: double.infinity, padding: const EdgeInsets.all(11), decoration: BoxDecoration( - color: EtmTokens.orange.withValues(alpha: 0.10), + color: EtmTokens.surfaceOf(context), borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl), - border: Border.all(color: EtmTokens.orange.withValues(alpha: 0.45)), + border: Border.all(color: EtmTokens.lineOf(context)), ), - child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(Icons.shuffle_rounded, size: 15, color: EtmTokens.orange), - const SizedBox(width: 7), - Expanded( - child: Text( - 'L\'ordre en place sur la box n\'est pas groupé par domaine : $doms ' - 'est coupé en deux par une autre famille de charges. L\'écran les regroupe ' - 'pour les afficher, donc ce que vous voyez n\'est pas l\'ordre réel.\n\n' - 'Le premier enregistrement REGROUPERA pour de bon, et changera l\'ordre de ' - 'service du surplus. Vérifiez les rangs avant d\'enregistrer.', - style: TextStyle( - fontSize: 11.5, height: 1.35, color: EtmTokens.inkOf(context)), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Icon(Icons.segment_rounded, size: 15, + color: EtmTokens.mutedOf(context)), + const SizedBox(width: 7), + Expanded( + child: Text( + 'L\'ordre de service n\'est pas groupé par domaine : $doms apparaît en ' + 'plusieurs endroits. Ce n\'est pas un défaut — l\'arbitrage se fait sur le ' + 'rang seul, le domaine n\'est qu\'une étiquette. Les blocs ci-dessous ' + 'suivent l\'ordre réel de la box, et l\'enregistrement ne le regroupera pas.', + style: TextStyle( + fontSize: 11.5, height: 1.35, color: EtmTokens.inkOf(context)), + ), + ), + ]), + Align( + alignment: Alignment.centerRight, + child: TextButton.icon( + onPressed: provider.groupByDomainNow, + icon: const Icon(Icons.merge_rounded, size: 16), + label: const Text('Regrouper par domaine'), + style: TextButton.styleFrom( + visualDensity: VisualDensity.compact, + foregroundColor: EtmTokens.brand, + textStyle: const TextStyle(fontSize: 12), + ), ), ), ]), @@ -500,7 +524,7 @@ class _DomainBlock extends StatelessWidget { // C'est le seul geste de cet écran qui n'a pas de repli : sans lui, l'ordre // des domaines ne se change pas du tout. ReorderableDragStartListener( - key: ValueKey('poignee-domaine-${group.domain}'), + key: ValueKey('poignee-plage-${group.loads.first.id}'), index: index, child: Container( color: Colors.transparent, @@ -541,7 +565,7 @@ class _DomainBlock extends StatelessWidget { // le même geste, dont l'un n'a pas de repli, valaient moins qu'un chemin sûr // plus une commande qui manquait. IconButton( - key: ValueKey('replier-${group.domain}'), + key: ValueKey('replier-plage-${group.loads.first.id}'), visualDensity: VisualDensity.compact, icon: Icon( replie @@ -560,6 +584,7 @@ class _DomainBlock extends StatelessWidget { padding: const EdgeInsets.only(bottom: 10), child: LoadConfigCard( entry: group.loads[i], + groupIndex: index, indexDansDomaine: i, totalDansDomaine: group.loads.length, provider: provider, @@ -582,6 +607,11 @@ class LoadConfigCard extends StatelessWidget { final int indexDansDomaine; final int totalDansDomaine; + /// Index de la PLAGE à laquelle la charge appartient. Depuis le 2026-08-29 un groupe est + /// une plage contiguë, donc un même domaine peut en occuper plusieurs : le désigner par + /// son code déplacerait la charge dans la première plage venue. + final int groupIndex; + final LoadConfigProvider provider; final LoadTelemetryProvider telemetry; final void Function(BuildContext, LoadConfigEntry) onConfigure; @@ -591,6 +621,7 @@ class LoadConfigCard extends StatelessWidget { required this.entry, required this.indexDansDomaine, required this.totalDansDomaine, + required this.groupIndex, required this.provider, required this.telemetry, required this.onConfigure, @@ -682,19 +713,15 @@ class LoadConfigCard extends StatelessWidget { key: ValueKey('monter-${entry.id}'), icone: Icons.keyboard_arrow_up_rounded, actif: indexDansDomaine > 0, - onTap: () => provider.reorderWithinDomain( - entry.domain ?? kUnclassifiedDomain, - indexDansDomaine, - indexDansDomaine - 1), + onTap: () => provider.reorderWithinGroup( + groupIndex, indexDansDomaine, indexDansDomaine - 1), ), _FlecheRang( key: ValueKey('descendre-${entry.id}'), icone: Icons.keyboard_arrow_down_rounded, actif: indexDansDomaine < totalDansDomaine - 1, - onTap: () => provider.reorderWithinDomain( - entry.domain ?? kUnclassifiedDomain, - indexDansDomaine, - indexDansDomaine + 2), + onTap: () => provider.reorderWithinGroup( + groupIndex, indexDansDomaine, indexDansDomaine + 2), ), ]), ]), diff --git a/test/evcharger_config_test.dart b/test/evcharger_config_test.dart index acd740c..2f5b732 100644 --- a/test/evcharger_config_test.dart +++ b/test/evcharger_config_test.dart @@ -286,16 +286,14 @@ void main() { reason: 'deux charges au rang 1 — la Terra est seule au rang 4'); }); - test('⚠️ le rang par défaut de la V2C CASSE la contiguïté des domaines', () { - // Constat de terrain, et il va plus loin que « l'ordre n'est pas choisi ». - // + test('le rang par défaut de la V2C donne un ordre NON GROUPÉ — et c\'est licite', () { // La V2C est créée au rang 1, que le chauffe-eau (ecs) occupe déjà. L'ordre à plat - // devient : ecs, ev, heating, ev, ev — le domaine `ev` est ENTRELACÉ avec heating. + // devient : ecs, ev, heating, ev, ev — le domaine `ev` apparaît en deux endroits. // - // L'app sait le détecter, et c'est vital : regrouper silencieusement puis réaplatir - // changerait l'arbitrage réel au premier enregistrement, sans que personne ne l'ait - // demandé. Ce test fige le fait que le cas se produit VRAIMENT sur une installation - // ordinaire — il n'était jamais apparu sur le banc avant l'arrivée de la V2C. + // Ce n'était pas « licite » à mes yeux jusqu'au 2026-08-29 : l'app l'annonçait comme + // un défaut à réparer. Le moteur a tranché — la contiguïté n'a JAMAIS été un + // invariant, le waterfall trie sur le rang seul et `domain` n'est pas lu par + // l'arbitre. Reste une information, pas une anomalie. final groupes = groupByDomain(banc()); expect(groupes.isContiguous, isFalse); expect(groupes.interleavedDomains, contains('ev')); @@ -303,22 +301,49 @@ void main() { final aPlat = groupes.flatOrder.map((e) => e.domain).toList(); expect(aPlat, ['ecs', 'ev', 'heating', 'ev', 'ev']); - final ev = groupes.groups.firstWhere((g) => g.domain == 'ev'); - expect(ev.loads.length, 3, reason: 'V2C + wallbox simulée + Terra orpheline'); + // ⚠️ Un groupe est une PLAGE CONTIGUË : le domaine `ev` en occupe deux, il ne se + // rassemble plus tout seul. C'est ce qui permet à l'affichage d'épouser l'ordre réel. + final plagesEv = groupes.groups.where((g) => g.domain == 'ev').toList(); + expect(plagesEv.length, 2, reason: 'une plage d\'une charge, une de deux'); + expect(plagesEv.map((g) => g.loads.length).toList(), [1, 2]); }); - test('réaplatir un ordre entrelacé le CHANGE — d\'où l\'avertissement', () { - // La preuve chiffrée de ce que l'écran doit annoncer avant d'enregistrer : la PAC - // passe du rang 2 au rang 5. Ce n'est pas un détail d'affichage, c'est l'ordre de - // service du surplus qui bascule. + test('réaplatir un ordre entrelacé NE LE CHANGE PLUS', () { + // C'est le cœur du correctif du 2026-08-29. Avant, réaplatir regroupait, et la PAC + // reculait du rang 2 au rang 5 — un déplacement que personne n'avait demandé, au + // premier enregistrement venu. Grouper à l'affichage oui, à l'écriture non. final groupes = groupByDomain(banc()); final avant = {for (final e in banc()) e.id: e.priority}; final apres = { for (final m in flattenToPayload(groupes.groups)) m['id']: m['priority'] }; expect(avant['pac-terrain'], 2); - expect(apres['pac-terrain'], 5, - reason: 'la PAC recule de trois rangs par le seul fait de regrouper'); + expect(apres['pac-terrain'], 3, + reason: 'la PAC garde sa POSITION — 3e de l\'ordre à plat, rangs renumérotés ' + 'sans permutation'); + // L'ordre à plat, lui, est identique à celui lu sur la box. + expect([for (final m in flattenToPayload(groupes.groups)) m['id']], + [for (final e in groupes.flatOrder) e.id]); + }); + + test('REGROUPER reste possible — mais c\'est un geste, et il déplace vraiment', () { + // Ce que l'enregistrement faisait en douce est maintenant explicite : le geste + // rassemble les plages, et la PAC recule bien du rang 3 au rang 5. Personne ne le + // subit ; quelqu'un le demande. + final groupes = groupByDomain(banc()); + final regroupe = groupContiguous(groupes.groups); + final apres = { + for (final m in flattenToPayload(regroupe)) m['id']: m['priority'] + }; + expect(apres['pac-terrain'], 5); + // ⚠️ La contiguïté se juge sur les rangs RÉÉCRITS, pas sur les entrées regroupées : + // `groupContiguous` range, `flattenToPayload` renumérote. Relire les entrées sans + // leurs nouveaux rangs les retrie sur les anciens — et rend l'ordre entrelacé qu'on + // vient de défaire. + final ecrit = [ + for (final m in flattenToPayload(regroupe)) LoadConfigEntry(m) + ]; + expect(groupByDomain(ecrit).isContiguous, isTrue); }); }); } diff --git a/test/merged_config_screen_test.dart b/test/merged_config_screen_test.dart index 8b87d42..37e3e96 100644 --- a/test/merged_config_screen_test.dart +++ b/test/merged_config_screen_test.dart @@ -14,6 +14,7 @@ import 'dart:io'; import 'package:etm_powersync_app/l10n/app_localizations.dart'; import 'package:etm_powersync_app/models/load_config_entry.dart'; import 'package:etm_powersync_app/providers/load_config_provider.dart'; +import 'package:etm_powersync_app/services/load_priority.dart'; import 'package:etm_powersync_app/providers/load_telemetry_provider.dart'; import 'package:etm_powersync_app/services/load_hardware.dart'; import 'package:etm_powersync_app/services/nymea_service.dart'; @@ -502,8 +503,12 @@ void main() { expect(texte, contains('V2C Trydan')); }); - testWidgets('la note d\'ordre entrelacé est à l\'écran, et annonce l\'effet de l\'enregistrement', + testWidgets('l\'ordre non groupé s\'INFORME et s\'offre à regrouper — il n\'alerte plus', (tester) async { + // Elle prévenait que l'enregistrement regrouperait. Il ne regroupe plus : un groupe + // est une plage contiguë, l'affichage épouse l'ordre réel, et réaplatir rend ce qui + // a été lu. La contiguïté n'a jamais été un invariant — l'arbitre trie sur le rang + // seul. Reste une information, et un geste pour qui veut grouper. final config = LoadConfigProvider()..adoptForTest(v2cConfig()); final telemetry = LoadTelemetryProvider() ..adoptForTest({'degradedMode': false, 'loads': const []}); @@ -513,8 +518,35 @@ void main() { final note = find.textContaining('n\'est pas groupé par domaine'); expect(note, findsOneWidget); - // Ce que l'enregistrement fera doit être dit AVANT, pas constaté après. - expect(tester.widget(note).data!, contains('REGROUPERA')); + final texte = tester.widget(note).data!; + expect(texte, contains('Ce n\'est pas un défaut')); + expect(texte, contains('ne le regroupera pas')); + // L'ancienne menace a disparu. + expect(texte, isNot(contains('REGROUPERA'))); + expect(texte, isNot(contains('coupé en deux'))); + // Et le regroupement devient un geste, offert et non subi. + expect(find.text('Regrouper par domaine'), findsOneWidget); + }); + + testWidgets('« Regrouper par domaine » pose un BROUILLON, il n\'écrit pas', + (tester) async { + // Le geste doit se voir et s'annuler avant écriture : c'est ce qui le distingue du + // regroupement subi qu'on vient de retirer. + final config = LoadConfigProvider()..adoptForTest(v2cConfig()); + final telemetry = LoadTelemetryProvider() + ..adoptForTest({'degradedMode': false, 'loads': const []}); + + await tester.pumpWidget(_harness(config: config, telemetry: telemetry)); + await tester.pump(); + expect(config.hasPendingChanges, isFalse); + + await tester.tap(find.text('Regrouper par domaine')); + await tester.pump(); + + expect(config.hasPendingChanges, isTrue, + reason: 'un brouillon, donc annulable — rien n\'est parti'); + expect(groupByDomain(config.entries).isContiguous, isFalse, + reason: 'la BOX porte toujours l\'ordre entrelacé : seul le brouillon a bougé'); }); testWidgets('un ordre déjà groupé et classé ne montre AUCUNE des deux notes', diff --git a/test/rangs_distincts_test.dart b/test/rangs_distincts_test.dart index 5038e94..9f7a076 100644 --- a/test/rangs_distincts_test.dart +++ b/test/rangs_distincts_test.dart @@ -168,33 +168,22 @@ void main() { expect(LoadConfigEntry(const {'id': 'a'}).rangPoseDOffice, isFalse); }); - test('⚠️ ce qui PART ne porte jamais `"auto"` — la box le refuserait en bloc', - () { - // Les chemins d'écriture recopient verbatim : sans ce nettoyage, un simple renommage - // renverrait la marque que le moteur a posée, et l'ensemble serait rejeté - // (EnergyErrorInvalidParameter) dès qu'une borne auto-provisionnée existe. - final envoye = sansRankOriginAuto([ - {'id': 'a', 'priority': 1, 'rankOrigin': 'auto'}, - {'id': 'b', 'priority': 2, 'rankOrigin': 'user'}, - {'id': 'c', 'priority': 3, 'rankOrigin': ''}, - {'id': 'd', 'priority': 4}, - ]); - - expect(envoye[0]['rankOrigin'], '', reason: 'auto → effacé'); - expect(envoye[1]['rankOrigin'], 'user', reason: 'user est permis, on n\'y touche pas'); - expect(envoye[2]['rankOrigin'], ''); - expect(envoye[3].containsKey('rankOrigin'), isFalse, - reason: 'une clé absente le reste — on n\'en fabrique pas'); - }); - - test('`"user"` PASSE le point de sortie — sinon le geste ne partirait jamais', () { - // Le filtre vise `"auto"`, que l'app n'a pas le droit d'écrire. `"user"` est - // précisément ce qu'elle a le droit d'écrire : le confondre avec `"auto"` rendrait - // le bouton « ce rang me convient » silencieusement inopérant. - final envoye = sansRankOriginAuto([ - {'id': 'a', 'priority': 1, 'rankOrigin': 'user'}, - ]); - expect(envoye.single['rankOrigin'], 'user'); + test('l\'app renvoie la charge utile TELLE QUELLE — plus aucun filtre', () { + // Le nettoyage de `"auto"` est retiré le 2026-08-29 : l'écho verbatim inchangé passe + // (`reconcileRankOrigin()` l'accepte quand le persisté le porte déjà). Filtrer encore + // effacerait une marque que la box aurait gardée — et le badge s'éteindrait sur une + // borne que personne n'a classée, au premier geste voisin. + final frais = [ + {'id': 'a', 'label': 'A', 'priority': 1, 'rankOrigin': 'auto'}, + {'id': 'b', 'label': 'B', 'priority': 2, 'rankOrigin': 'user'}, + ]; + final renomme = [ + for (final m in frais) + if (m['id'] == 'b') {...m, 'label': 'B bis'} else Map.from(m) + ]; + expect(renomme[0]['rankOrigin'], 'auto', + reason: 'la marque du moteur traverse un geste qui ne la concerne pas'); + expect(diffPayloads(frais, renomme).single, contains('label')); }); test('adopter n\'est pas classer : le rang ne bouge pas', () { @@ -214,22 +203,5 @@ void main() { expect(diffPayloads([avant], [apres]).length, 1); }); - test('le nettoyage ne touche à RIEN d\'autre', () { - final avant = { - 'id': 'a', - 'label': 'A', - 'priority': 1, - 'rankOrigin': 'auto', - 'relays': [ - {'thingId': '{x}', 'powerW': 500} - ], - }; - final apres = sansRankOriginAuto([avant]).single; - for (final k in avant.keys) { - if (k == 'rankOrigin') continue; - expect(apres[k], avant[k], reason: 'clé $k altérée'); - } - expect(apres.keys.length, avant.keys.length); - }); }); } diff --git a/test/reorder_drag_test.dart b/test/reorder_drag_test.dart index 89bc7ee..e839da5 100644 --- a/test/reorder_drag_test.dart +++ b/test/reorder_drag_test.dart @@ -142,7 +142,7 @@ void main() { await tester.pumpWidget(_montage(config: config, dansUnListView: false)); await tester.pumpAndSettle(); - await _glisserCle(tester, 'poignee-domaine-ecs', 'load-pac-terrain'); + await _glisserCle(tester, 'poignee-plage-chauffe-eau', 'load-pac-terrain'); expect(_ordre(config), ['pac-terrain', 'chauffe-eau'], reason: 'le glissement du domaine doit produire un nouvel ordre'); @@ -156,7 +156,7 @@ void main() { await tester.pumpWidget(_montage(config: config, dansUnListView: true)); await tester.pumpAndSettle(); - await _glisserCle(tester, 'poignee-domaine-ecs', 'load-pac-terrain'); + await _glisserCle(tester, 'poignee-plage-chauffe-eau', 'load-pac-terrain'); expect(_ordre(config), ['pac-terrain', 'chauffe-eau'], reason: 'si CE test échoue seul, le ListView parent avale le geste'); @@ -189,12 +189,14 @@ void main() { expect(find.byKey(const ValueKey('descendre-chauffe-eau')), findsNothing); expect(find.byKey(const ValueKey('monter-pac-terrain')), findsNothing); // Mais le domaine, lui, se glisse. - expect(find.byKey(const ValueKey('poignee-domaine-ecs')), findsOneWidget); + // La poignée porte l'id de la première charge de la PLAGE, pas le domaine : deux + // plages d'un même domaine partageraient sinon la même clé. + expect(find.byKey(const ValueKey('poignee-plage-chauffe-eau')), findsOneWidget); }); test('le rang envoyé à la box se renumérote depuis la POSITION', () { final config = LoadConfigProvider()..adoptForTest(_deuxDansUnDomaine()); - config.reorderWithinDomain('ecs', 0, 2); // 1re → après la 2e + config.reorderWithinGroup(0, 0, 2); // 1re → après la 2e, dans la plage 0 final payload = flattenToPayload(config.groups); expect([for (final m in payload) m['id']],