/// **`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/providers/load_config_provider.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('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', () { // La charge utile d'une adoption ne change QUE `rankOrigin`. Un geste qui déplacerait // la charge en même temps rendrait l'affirmation fausse — « ce rang me va » ne peut // pas porter sur un rang qu'on vient de changer. final avant = { 'id': 'a', 'label': 'A', 'priority': 3, 'rankOrigin': 'auto', 'domain': 'ev', }; final apres = {...avant, 'rankOrigin': 'user'}; expect(apres['priority'], avant['priority']); expect(diffPayloads([avant], [apres]).single, contains('rankOrigin')); expect(diffPayloads([avant], [apres]).length, 1); }); }); group('sortir une charge de sa plage — « Placer en dernier »', () { /// L'ordre du banc au 2026-08-29, une fois REGROUPÉ : la wallbox 2 est prisonnière du /// bloc `ev`, et plus rien ne l'en sort — c'est le manque que ce geste comble. List> groupe() => [ {'id': 'chauffe-eau', 'label': 'chauffe-eau', 'priority': 1, 'domain': 'ecs'}, {'id': 'v2c', 'label': 'V2C', 'priority': 2, 'domain': 'ev'}, {'id': 'sim1', 'label': 'wallbox', 'priority': 3, 'domain': 'ev'}, {'id': 'sim2', 'label': 'wallbox 2', 'priority': 4, 'domain': 'ev'}, {'id': 'pac', 'label': 'PAC', 'priority': 5, 'domain': 'heating'}, ]; test('la charge quitte sa plage et rejoint la queue', () { final p = LoadConfigProvider()..adoptForTest(groupe()); p.moveToTail('sim2'); final payload = flattenToPayload(p.groups); expect([for (final m in payload) m['id']], ['chauffe-eau', 'v2c', 'sim1', 'pac', 'sim2']); expect(payload.last['priority'], 5); // Elle est SEULE dans sa plage : le glissement de plage la portera partout ailleurs. expect(p.groups.last.loads.length, 1); expect(p.groups.last.loads.single.id, 'sim2'); }); test('rien ne part : c\'est un brouillon', () { final p = LoadConfigProvider()..adoptForTest(groupe()); p.moveToTail('sim2'); expect(p.hasPendingChanges, isTrue); // La configuration adoptée, elle, n'a pas bougé. expect(p.entries.firstWhere((e) => e.id == 'sim2').priority, 4); }); test('déjà en queue : aucun brouillon — un bouton qui ne fait rien use la confiance', () { final p = LoadConfigProvider()..adoptForTest(groupe()); p.moveToTail('pac'); expect(p.hasPendingChanges, isFalse); }); test('les rangs restent distincts, et rien d\'autre ne bouge', () { final p = LoadConfigProvider()..adoptForTest(groupe()); p.moveToTail('v2c'); final payload = flattenToPayload(p.groups); final rangs = [for (final m in payload) m['priority']]; expect(rangs.toSet().length, rangs.length, reason: '+etm27 refuse les doublons'); // Les autres gardent leur ordre relatif : sortir une charge n'est pas reclasser. expect([for (final m in payload) m['id']], ['chauffe-eau', 'sim1', 'sim2', 'pac', 'v2c']); }); test('une charge seule dans sa plage RESTE sortable ailleurs qu\'en queue', () { // Cas du banc avant regroupement : sim2 est déjà seule et déjà dernière. Le geste // ne s'offre pas — mais le glissement de plage, lui, reste disponible. final p = LoadConfigProvider()..adoptForTest([ {'id': 'a', 'label': 'A', 'priority': 1, 'domain': 'ecs'}, {'id': 'b', 'label': 'B', 'priority': 2, 'domain': 'ev'}, ]); p.moveToTail('b'); expect(p.hasPendingChanges, isFalse); p.reorderDomains(1, 0); expect(p.hasPendingChanges, isTrue); expect([for (final m in flattenToPayload(p.groups)) m['id']], ['b', 'a']); }); }); }