/// **`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); }); }); group('rankOrigin — ce que l\'app a le droit d\'écrire', () { test('`""` se lit comme « rien d\'affirmé », pas comme une valeur', () { // Depuis +etm27 la clé sort TOUJOURS, vide quand rien n'est déclaré — même // convention que `domain` et `meterThingId`. Relevé sur `.75` le 2026-08-29 : les // quatre charges du banc portent `rankOrigin: ""`. La traiter comme une valeur // afficherait un badge sur toute l'installation. final e = LoadConfigEntry(const {'id': 'a', 'rankOrigin': ''}); expect(e.rankOrigin, isNull); expect(e.rangPoseDOffice, isFalse); }); test('`"auto"` se lit, et il est le SEUL à allumer le badge', () { expect(LoadConfigEntry(const {'id': 'a', 'rankOrigin': 'auto'}).rangPoseDOffice, isTrue); expect(LoadConfigEntry(const {'id': 'a', 'rankOrigin': 'user'}).rangPoseDOffice, isFalse); 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('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); }); }); }