diff --git a/TODO.md b/TODO.md index a1ab35c..48600c0 100644 --- a/TODO.md +++ b/TODO.md @@ -2,6 +2,29 @@ ## Ouvert au 2026-08-27 +- [ ] 🔜 **`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 + déploiement** : `validateSet()` porte le contrôle (`tiedPriority`), l'écran nomme les + deux charges et leur rang, et le chemin de suppression — qui réécrit verbatim, donc + transporte les rangs de la box — refuse plutôt que d'envoyer une égalité héritée. Huit + tests, dont le réaplatissage après **chaque** permutation. Le refus de la box est muet + (le motif ne franchit pas la frontière RPC) : sans ce miroir, le symptôme aurait été + « mon glissement ne s'enregistre pas ». + - [ ] **`o:rankOrigin` — badge « rang par défaut, pas un choix ».** `"auto"` écrit par le + moteur quand il crée une entrée d'office, **effacé dès qu'un rang différent arrive** + pour cet id. **Absent = rien d'affirmé**, jamais « choisi par un humain » — même règle + que `measurement.source`. `"user"` est ce qu'un client écrit pour dire « j'ai vu ce + défaut et il me va » sans déplacer la charge ; l'app ne peut **jamais** écrire `"auto"`, + le moteur le refuse. C'est l'équivalent, pour une charge arrivée seule, de la note de + rangs ex æquo — et il faudra décider si le badge remplace cette note ou s'y ajoute. + - [ ] **Les bornes auto-provisionnées entrent en QUEUE (`1 + max`)**, plus en tête. Une + borne détectée après la mise en service ne passe donc plus devant l'ECS classé exprès ; + sur une installation neuve, la liste étant vide, le VE garde le rang 1. Vérifier que la + note d'ordre entrelacé et celle de rangs ex æquo se taisent bien dans ce nouveau cas — + elles décrivaient un défaut qui disparaît. + + - [x] ~~⏳ Retirer l'alias `measuredW`~~ — **fait le 2026-08-28**, `1.15.2+etm26` déployé sur `.75` et vérifié par sonde : `measuredW` a disparu de la charge utile, `measurement` est en ligne. `MeasurementSource.legacy` est parti avec lui ; ce qui reste, diff --git a/lib/providers/load_config_provider.dart b/lib/providers/load_config_provider.dart index a556188..aa94afd 100644 --- a/lib/providers/load_config_provider.dart +++ b/lib/providers/load_config_provider.dart @@ -364,6 +364,26 @@ class LoadConfigProvider extends ChangeNotifier { final reste = payloadWithoutLoad(frais, loadId); if (reste == null) return 'NotFound'; + // ⚠️ `1.15.2+etm27` refuse EN BLOC un ensemble où deux charges partagent un rang — et + // le motif ne franchit pas la frontière RPC. + // + // Ce chemin réécrit les survivantes VERBATIM, donc il transporte les rangs tels que la + // box les porte. Or c'est le plugin lui-même qui a créé des égalités avant `+etm27` + // (borne détectée au rang 1, sur une installation où le rang 1 était pris). Une + // suppression parfaitement légitime serait donc refusée, sans cause visible. + // + // On refuse ICI, avec la raison — plutôt que de renuméroter en douce : un geste de + // nettoyage qui redistribue les rangs devient un geste d'arbitrage, et c'est + // exactement ce que ce chemin s'interdit. + final apres = [for (final m in reste) LoadConfigEntry(m)]; + if (tiedByPriority(apres).isNotEmpty) { + _saveError = 'La configuration porte des rangs à égalité, que la box refuse ' + 'depuis 1.15.2+etm27. Classez les charges avant de retirer celle-ci.'; + _saveState = LoadSaveState.rejected; + notifyListeners(); + return 'TiedRanks'; + } + // 3. RÉÉCRIRE. _expectedSignature = _signatureOfPayload(reste); _conflict = null; diff --git a/lib/services/load_priority.dart b/lib/services/load_priority.dart index 783bb9b..1be39ad 100644 --- a/lib/services/load_priority.dart +++ b/lib/services/load_priority.dart @@ -303,25 +303,43 @@ List applyDefaultDomainOrder(List groups) { /// Conflit détecté par [validateSet], suffisamment décrit pour être rendu en français. class LoadSetConflict { - /// `duplicateId` ou `thingClaimedTwice`. + /// `duplicateId`, `thingClaimedTwice` ou `tiedPriority`. final String kind; final String? id; final String? thingId; final String? firstLabel; final String? secondLabel; + /// Rang partagé, pour `tiedPriority`. + final int? rank; + const LoadSetConflict.duplicateId(this.id) : kind = 'duplicateId', thingId = null, firstLabel = null, - secondLabel = null; + secondLabel = null, + rank = null; const LoadSetConflict.thingClaimedTwice({ required this.thingId, required this.firstLabel, required this.secondLabel, }) : kind = 'thingClaimedTwice', - id = null; + id = null, + rank = null; + + /// **Deux charges au même rang** — refusé en bloc depuis `1.15.2+etm27`. + /// + /// Le motif du refus ne franchit pas la frontière RPC : sans ce miroir, le symptôme est + /// « mon glissement ne s'enregistre pas », sans cause visible. C'est exactement la + /// famille de panne muette que ce dépôt traque. + const LoadSetConflict.tiedPriority({ + required this.rank, + required this.firstLabel, + required this.secondLabel, + }) : kind = 'tiedPriority', + id = null, + thingId = null; } /// Rejoue la validation d'ensemble du plugin **avant** l'envoi. @@ -329,17 +347,32 @@ class LoadSetConflict { /// Miroir de `LoadConfigStore::validateSet()` (`loadconfigstore.cpp:83-112`) : /// unicité des identifiants sur **toutes** les charges, unicité des Things revendiqués /// **parmi les `enabled` seulement** — une charge désactivée ne construit aucun -/// adaptateur, donc ne revendique rien. +/// adaptateur, donc ne revendique rien — et, depuis `1.15.2+etm27`, **unicité des rangs**. +/// +/// Ce troisième contrôle vaut plus que les deux autres, parce que le refus qu'il anticipe +/// est **muet** : le motif ne franchit pas la frontière RPC, et le symptôme au doigt est +/// « mon glissement ne s'enregistre pas ». /// /// C'est un **confort d'affichage**. La box reste l'autorité : si elle refuse, c'est son /// refus qu'il faut montrer. LoadSetConflict? validateSet(List entries) { final ids = {}; final owner = {}; // thingId normalisé → libellé de la charge + final rangs = {}; // priority → libellé de la première charge vue for (final c in entries) { if (!ids.add(c.id)) return LoadSetConflict.duplicateId(c.id); + // ⚠️ `1.15.2+etm27` : deux charges au même rang font refuser TOUT l'ensemble. Le + // contrôle porte sur ce qui part, y compris une charge désactivée — le moteur ne les + // exclut pas de cette règle-ci, contrairement à la revendication de Thing. + final vu = rangs[c.priority]; + if (vu != null) { + return LoadSetConflict.tiedPriority( + rank: c.priority, firstLabel: vu, secondLabel: c.label); + } + rangs[c.priority] = c.label; + if (!c.enabled) continue; // ne construit aucun adaptateur : ne revendique rien for (final thing in c.claimedThingIds) { diff --git a/lib/widgets/controllable_loads_section.dart b/lib/widgets/controllable_loads_section.dart index f7fc6da..947e4d4 100644 --- a/lib/widgets/controllable_loads_section.dart +++ b/lib/widgets/controllable_loads_section.dart @@ -903,9 +903,14 @@ class _RejectionNote extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - conflit != null - ? 'Deux charges revendiquent le même appareil.' - : 'La box a refusé l\'enregistrement${err == null ? '' : ' ($err)'}.', + switch (conflit?.kind) { + 'thingClaimedTwice' => 'Deux charges revendiquent le même appareil.', + 'duplicateId' => 'Deux charges portent le même identifiant.', + 'tiedPriority' => + 'Deux charges se retrouvent au rang ${conflit!.rank} : ' + '${conflit.firstLabel} et ${conflit.secondLabel}.', + _ => 'La box a refusé l\'enregistrement${err == null ? '' : ' ($err)'}.', + }, style: const TextStyle( fontSize: 12.5, fontWeight: FontWeight.w700, @@ -913,15 +918,27 @@ class _RejectionNote extends StatelessWidget { ), const SizedBox(height: 4), Text( - conflit != null - // LM-201 : deux charges sur un canal unique se contrediraient, et le - // budget compterait la même puissance deux fois. Ce n'est pas une - // limite technique — c'est le modèle. - ? 'Un appareil ne peut appartenir qu\'à une seule charge : deux ' - 'charges sur un même canal de commande se contrediraient.' - : 'Le refus porte sur l\'ensemble : rien n\'a été modifié. La règle ' - 'enfreinte et le libellé en cause sont dans le journal de la box ' - '(journalctl -u nymead).', + switch (conflit?.kind) { + // LM-201 : deux charges sur un canal unique se contrediraient, et le + // budget compterait la même puissance deux fois. Ce n'est pas une + // limite technique — c'est le modèle. + 'thingClaimedTwice' => + 'Un appareil ne peut appartenir qu\'à une seule charge : deux ' + 'charges sur un même canal de commande se contrediraient.', + 'duplicateId' => + 'Chaque charge a un identifiant unique côté box — deux entrées ne ' + 'peuvent pas le partager.', + // Depuis +etm27 la box refuse TOUT l'ensemble sur une égalité de rang, et + // son motif ne franchit pas la frontière RPC : sans ce message, le + // symptôme serait « ça ne s'enregistre pas », sans cause visible. + 'tiedPriority' => + 'Depuis la version 1.15.2+etm27, la box refuse toute configuration où ' + 'deux charges partagent un rang. Classez-les l\'une après l\'autre ' + '— rien n\'a été envoyé.', + _ => 'Le refus porte sur l\'ensemble : rien n\'a été modifié. La règle ' + 'enfreinte et le libellé en cause sont dans le journal de la box ' + '(journalctl -u nymead).', + }, style: TextStyle( fontSize: 11.5, height: 1.35, diff --git a/lib/widgets/load_cards.dart b/lib/widgets/load_cards.dart index 31554a0..feebe28 100644 --- a/lib/widgets/load_cards.dart +++ b/lib/widgets/load_cards.dart @@ -477,6 +477,9 @@ class _BoutonRetirerState extends State<_BoutonRetirer> { 'NotFound' => 'Cette charge n\'est plus dans la configuration de la box — rien à retirer.', 'NoService' => 'Aucune connexion à la box.', + 'TiedRanks' => + 'Deux charges partagent un rang, et la box refuse alors toute la configuration ' + '(1.15.2+etm27). Classez-les d\'abord — rien n\'a été envoyé.', 'RereadFailed' => 'Suppression envoyée, mais impossible de relire la configuration pour le ' 'vérifier. Rouvrez l\'écran avant tout autre geste.', diff --git a/test/load_priority_test.dart b/test/load_priority_test.dart index 8d89c0f..26da530 100644 --- a/test/load_priority_test.dart +++ b/test/load_priority_test.dart @@ -197,8 +197,10 @@ void main() { test('un Thing commandé par deux charges ACTIVES est refusé', () { final relay = [{'thingId': '{8538782f-2c8c-4a30-bfce-c8140f791c9b}', 'powerW': 500}]; final c = validateSet([ - LoadConfigEntry({'id': 'a', 'label': 'A', 'adapter': 'relay-router', 'enabled': true, 'relays': relay}), - LoadConfigEntry({'id': 'b', 'label': 'B', 'adapter': 'relay-router', 'enabled': true, 'relays': relay}), + // Rangs DISTINCTS : depuis +etm27 une égalité fait refuser l'ensemble, et le + // miroir la voit en premier — elle masquerait la règle éprouvée ici. + LoadConfigEntry({'id': 'a', 'label': 'A', 'priority': 1, 'adapter': 'relay-router', 'enabled': true, 'relays': relay}), + LoadConfigEntry({'id': 'b', 'label': 'B', 'priority': 2, 'adapter': 'relay-router', 'enabled': true, 'relays': relay}), ]); expect(c?.kind, 'thingClaimedTwice'); expect(c?.firstLabel, 'A'); @@ -208,8 +210,8 @@ void main() { test('une charge DÉSACTIVÉE ne revendique rien — pas de conflit', () { final relay = [{'thingId': '{8538782f-2c8c-4a30-bfce-c8140f791c9b}', 'powerW': 500}]; final c = validateSet([ - LoadConfigEntry({'id': 'a', 'label': 'A', 'adapter': 'relay-router', 'enabled': true, 'relays': relay}), - LoadConfigEntry({'id': 'b', 'label': 'B', 'adapter': 'relay-router', 'enabled': false, 'relays': relay}), + LoadConfigEntry({'id': 'a', 'label': 'A', 'priority': 1, 'adapter': 'relay-router', 'enabled': true, 'relays': relay}), + LoadConfigEntry({'id': 'b', 'label': 'B', 'priority': 2, 'adapter': 'relay-router', 'enabled': false, 'relays': relay}), ]); expect(c, isNull); }); diff --git a/test/rangs_distincts_test.dart b/test/rangs_distincts_test.dart new file mode 100644 index 0000000..24400f4 --- /dev/null +++ b/test/rangs_distincts_test.dart @@ -0,0 +1,151 @@ +/// **`1.15.2+etm27` refuse en bloc un ensemble où deux charges partagent un rang.** +/// +/// Le motif du refus ne franchit pas la frontière RPC : le symptôme au doigt serait « mon +/// glissement ne s'enregistre pas », sans cause visible. Ces tests vérifient les trois +/// choses que ça exige de l'app, **avant** le déploiement : +/// +/// 1. le réaplatissage produit toujours des rangs distincts, quel que soit l'état d'où il +/// part — y compris un ordre entrelacé ou déjà à égalité ; +/// 2. rien ne part à la box tant que le brouillon n'est pas cohérent — le miroir client +/// voit l'égalité avant l'envoi ; +/// 3. le chemin de suppression, qui réécrit VERBATIM, refuse plutôt que de transporter une +/// égalité que la box rejettera. +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:etm_powersync_app/models/load_config_entry.dart'; +import 'package:etm_powersync_app/services/load_priority.dart'; +import 'package:flutter_test/flutter_test.dart'; + +List _banc([String fixture = 'loadconfig_hems75_v2c.json']) { + final raw = jsonDecode(File('test/fixtures/$fixture').readAsStringSync()); + return [ + for (final m in ((raw as Map)['loadConfigs'] as List)) + LoadConfigEntry(Map.from(m as Map)), + ]; +} + +/// Les rangs d'une charge utile prête à partir. +List _rangs(List> payload) => + [for (final m in payload) m['priority'] as int]; + +void main() { + group('le réaplatissage rend TOUJOURS des rangs distincts', () { + test('sur le banc, dont l\'ordre porte deux charges au rang 1', () { + // C'est l'état réel du 2026-08-28 : la V2C créée d'office au rang 1 que le + // chauffe-eau occupait déjà. Sous +etm27, réécrire cet ensemble tel quel serait + // refusé — le réaplatissage doit donc le réparer, et il le fait. + final entrees = _banc(); + expect(tiedByPriority(entrees), isNotEmpty, reason: 'prémisse du test'); + + final rangs = _rangs(flattenToPayload(groupByDomain(entrees).groups)); + expect(rangs.toSet().length, rangs.length); + expect(rangs, List.generate(entrees.length, (i) => i + 1)); + }); + + test('sur un ordre entrelacé', () { + final rangs = _rangs( + flattenToPayload(groupByDomain(_banc('loadconfig_interleaved.json')).groups)); + expect(rangs.toSet().length, rangs.length); + }); + + test('après CHAQUE déplacement, pas seulement à la fin', () { + // Le glissement produit des états intermédiaires : ce qui compte est qu'aucun d'eux + // ne puisse partir avec une égalité, pas seulement l'état final. + var groupes = groupByDomain(_banc()).groups; + for (var i = 0; i < groupes.length; i++) { + for (var j = 0; j < groupes.length; j++) { + final permute = [...groupes]; + permute.insert(j, permute.removeAt(i)); + final rangs = _rangs(flattenToPayload(permute)); + expect(rangs.toSet().length, rangs.length, + reason: 'domaine $i → $j produit une égalité'); + } + } + // Et à l'intérieur d'un domaine. + groupes = groupByDomain(_banc()).groups; + final ev = groupes.indexWhere((g) => g.domain == 'ev'); + if (ev >= 0 && groupes[ev].loads.length > 1) { + final charges = [...groupes[ev].loads]; + charges.insert(0, charges.removeLast()); + final refait = [ + for (final (i, g) in groupes.indexed) + if (i == ev) DomainGroup(domain: g.domain, loads: charges) else g + ]; + final rangs = _rangs(flattenToPayload(refait)); + expect(rangs.toSet().length, rangs.length); + } + }); + }); + + group('rien ne part si le brouillon n\'est pas cohérent', () { + test('le miroir client VOIT l\'égalité de rang', () { + // Sans ce contrôle, la box refuserait tout l'ensemble sans dire pourquoi. + final conflit = validateSet(_banc()); + expect(conflit, isNotNull); + expect(conflit!.kind, 'tiedPriority'); + expect(conflit.rank, 1); + expect([conflit.firstLabel, conflit.secondLabel], + containsAll(['chauffe-eau', 'V2C Trydan'])); + }); + + test('un ensemble réaplati passe le miroir', () { + // Le chemin normal de l'écran : on réaplatit, donc on n'envoie jamais d'égalité. + final aplati = [ + for (final m in flattenToPayload(groupByDomain(_banc()).groups)) + LoadConfigEntry(m) + ]; + expect(validateSet(aplati), isNull); + }); + + test('l\'égalité est vue AVANT les autres conflits qu\'elle masquerait', () { + // Une charge désactivée compte aussi : le moteur ne l'exclut pas de cette règle-ci, + // contrairement à la revendication de Thing. + final entrees = [ + LoadConfigEntry(const { + 'id': 'a', + 'label': 'A', + 'priority': 2, + 'adapter': 'evcharger', + 'enabled': true + }), + LoadConfigEntry(const { + 'id': 'b', + 'label': 'B', + 'priority': 2, + 'adapter': 'evcharger', + 'enabled': false + }), + ]; + expect(validateSet(entrees)?.kind, 'tiedPriority'); + }); + }); + + group('la suppression ne transporte pas une égalité', () { + test('retirer une charge NE RÉPARE PAS les rangs — et c\'est voulu', () { + // Le chemin de suppression réécrit verbatim : les rangs survivent tels quels. C'est + // ce qui rend le garde-fou nécessaire, et c'est aussi ce qui empêche un geste de + // nettoyage de devenir un geste d'arbitrage. + final frais = [ + for (final e in _banc()) Map.from(e.raw) + ]; + final reste = payloadWithoutLoad( + frais, '{88160e45-7df2-48cc-9639-f0d15ae6e382}')!; + final apres = [for (final m in reste) LoadConfigEntry(m)]; + expect(tiedByPriority(apres), isNotEmpty, + reason: 'l\'égalité chauffe-eau / V2C survit à la suppression — d\'où le refus'); + }); + + test('quand la charge retirée PORTAIT l\'égalité, la suppression la résout', () { + final frais = [ + {'id': 'a', 'label': 'A', 'priority': 1, 'adapter': 'relay-router'}, + {'id': 'b', 'label': 'B', 'priority': 1, 'adapter': 'evcharger'}, + {'id': 'c', 'label': 'C', 'priority': 2, 'adapter': 'sg-ready'}, + ]; + final reste = payloadWithoutLoad(frais, 'b')!; + expect(tiedByPriority([for (final m in reste) LoadConfigEntry(m)]), isEmpty); + }); + }); +}