feat(config): un groupe devient une PLAGE — l'enregistrement ne regroupe plus
Le moteur a tranché l'entrelacement, et son argument va plus loin que le mien : 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. Il ne suffisait donc pas d'adoucir la note : c'est le modèle d'affichage qui la produisait. Un groupe était un SEAU par domaine, rassemblant les charges où qu'elles soient dans l'ordre — donc l'écran ne pouvait pas représenter un ordre entrelacé, et comme l'enregistrement réaplatit ce qui est affiché, TOUT enregistrement regroupait. La PAC reculait du rang 2 au rang 5 sans que personne l'ait demandé. Un groupe est maintenant une PLAGE CONTIGUË. 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 qui a été lu. Test à l'appui : la PAC garde sa position, et l'ordre à plat écrit est identique à celui lu sur la box. Le regroupement reste possible, mais devient un GESTE : bouton « Regrouper par domaine », qui pose un brouillon — visible, annulable, rien ne part avant save(). Ce que l'enregistrement faisait en douce, quelqu'un le demande désormais. Et la note cesse d'alerter : l'ordre non groupé est une information, pas un défaut. L'annoncer comme une anomalie poussait à réparer un ordre que personne n'avait cassé — d'autant que depuis +etm27 une borne détectée entre en queue, ce qui produit un ordre non groupé sur une installation parfaitement saine. Trois conséquences trouvées en chemin, toutes dues au fait qu'un domaine peut désormais occuper plusieurs plages : · la clé de la liste réordonnable portait le DOMAINE → deux plages d'un même domaine partageaient une clé, et la liste cassait sur une assertion de sémantiques, loin de la cause. Elle porte l'id de la première charge de la plage. · le repli suivait le domaine → deux plages se repliaient ensemble. · reorderWithinDomain(domain) désignait la première plage venue → reorderWithinGroup(index). · assignDomain rejoint la DERNIÈRE plage du domaine, pas la première : remonter la charge dans la première la ferait sauter des rangs que personne n'a touchés. Retiré au passage : sansRankOriginAuto(). L'écho verbatim inchangé passe — mesuré côté moteur sur marque fraîche — donc la marque survit 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. Noté au TODO : supprimer l'entrée d'une borne dont le Thing existe encore ne la recrée pas (défaut moteur en cours de correction). Le bouton réservé aux THING_MISSING protège déjà de ce cas — ne pas l'élargir. 226 tests, 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:
parent
3f5f6e0d9f
commit
a00a9209e9
9
TODO.md
9
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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -129,49 +129,6 @@ List<LoadConfigEntry> tiedByPriority(List<LoadConfigEntry> 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<Map<String, dynamic>> sansRankOriginAuto(List<Map<String, dynamic>> 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<LoadConfigEntry> entries) {
|
||||
return c != 0 ? c : a.id.compareTo(b.id);
|
||||
});
|
||||
|
||||
final order = <String>[];
|
||||
final buckets = <String, List<LoadConfigEntry>>{};
|
||||
// ── 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 = <DomainGroup>[];
|
||||
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 = <String>[];
|
||||
for (final key in order) {
|
||||
final idx = <int>[];
|
||||
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 = <String, int>{};
|
||||
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<DomainGroup> groupContiguous(List<DomainGroup> groups) {
|
||||
final ordre = <String>[];
|
||||
final parDomaine = <String, List<LoadConfigEntry>>{};
|
||||
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`.
|
||||
///
|
||||
|
||||
@ -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<String?> setLoadConfig(List<Map<String, dynamic>> 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<String?> setLoadConfig(List<Map<String, dynamic>> 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';
|
||||
|
||||
@ -58,10 +58,11 @@ class ControllableLoadsSection extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _ControllableLoadsSectionState extends State<ControllableLoadsSection> {
|
||||
/// 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<ControllableLoadsSection> {
|
||||
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<ControllableLoadsSection> {
|
||||
}
|
||||
}
|
||||
|
||||
/// **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),
|
||||
),
|
||||
]),
|
||||
]),
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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<Text>(note).data!, contains('REGROUPERA'));
|
||||
final texte = tester.widget<Text>(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',
|
||||
|
||||
@ -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<String, dynamic>.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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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']],
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user