/// **`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); }); }); }