/// Les bornes **en configuration** — l'invariant `loads[] ⊆ GetLoadConfig`, rétabli. /// /// 3g-1 avait rompu l'inclusion sans le signaler : les bornes entraient dans `loads[]` de /// `GetLoadTelemetry` sans figurer dans aucune `GetLoadConfig`. C'était la charge fantôme /// que le lot B-bis avait supprimée, revenue par l'autre bout — et l'app s'appuie sur cet /// invariant pour résoudre libellé, domaine et rang de **toute** charge publiée. /// /// Depuis `+etm23`, toute borne détectée reçoit d'office une entrée `LoadConfig`. La /// fixture est le dump réel de `.75` en `+etm24` : quatre entrées là où il y en avait deux. /// /// Ce que ces tests protègent, et qui n'est pas évident : /// /// 1. une borne **ne porte aucune charge utile de mécanisme**, et ne doit donc pas tomber /// dans le repli « modulable » — l'écran lui proposerait une puissance nominale et des /// paliers qui n'existent pas pour elle ; /// 2. l'aller-retour verbatim reste **neutre** avec ces entrées (vérifié aussi sur machine /// le 2026-08-27, verdict « IDENTIQUE ») ; /// 3. le glissement les traite **sans cas particulier** — c'est tout l'intérêt d'avoir une /// seule liste ; /// 4. le rang par défaut **dégénère en égalité**, et l'ordre affiché avant réglage est un /// artefact qu'il faut nommer. library; import 'dart:convert'; import 'dart:io'; import 'package:etm_powersync_app/models/load_config_entry.dart'; import 'package:etm_powersync_app/services/load_mechanism_switch.dart'; import 'package:etm_powersync_app/services/load_priority.dart'; import 'package:flutter_test/flutter_test.dart'; List _banc() { final raw = File('test/fixtures/loadconfig_hems75_evcharger.json').readAsStringSync(); final list = (jsonDecode(raw) as Map)['loadConfigs'] as List; return [ for (final m in list.whereType()) LoadConfigEntry(Map.from(m)), ]; } /// Les charges utiles BRUTES du banc — pas les entrées typées : c'est justement ce que la /// suppression doit préserver. List> _bancRaw() { final raw = File('test/fixtures/loadconfig_hems75_v2c.json').readAsStringSync(); return ((jsonDecode(raw) as Map)['loadConfigs'] as List) .whereType() .map((m) => Map.from(m)) .toList(); } void main() { group('3g-2 — les bornes ont une entrée de configuration', () { test('le dump du banc porte quatre charges, dont deux bornes', () { final e = _banc(); expect(e.length, 4); final bornes = e.where((x) => x.isEvCharger).toList(); expect(bornes.map((b) => b.label), containsAll(['Simulated wallbox', 'Terra AC Charger (TCP)'])); for (final b in bornes) { expect(b.adapter, 'evcharger'); expect(b.mode, 'dynamic'); expect(b.domain, 'ev'); expect(b.enabled, isTrue); } }); test('une borne ne tombe PAS dans le repli « modulable »', () { // C'était le cas avant : `isVariableLoad` étant défini par défaut, l'écran de // mécanisme offrait à une borne une puissance nominale et des paliers — des // réglages qui n'existent pas pour elle, et dont l'écriture partirait quand même. final borne = _banc().firstWhere((x) => x.isEvCharger); expect(borne.isVariableLoad, isFalse); expect(borne.isRelayRouter, isFalse); expect(borne.isSgReady, isFalse); // Et son adaptateur n'est pas dans la liste des mécanismes proposables : proposer // une bascule reviendrait à offrir de transformer une borne en routeur de relais. expect(kMecanismes, isNot(contains('evcharger'))); }); test('elle ne porte AUCUNE charge utile de mécanisme', () { // Délibéré côté plugin : les limites d'une borne viennent du Thing et changent avec // le véhicule branché. Les déclarer ici en ferait une seconde source de vérité. final borne = _banc().firstWhere((x) => x.isEvCharger); expect(borne.relays, isEmpty); expect(borne.sgReadyStates, isEmpty); expect(borne.powerLevelsInt, isEmpty); expect(borne.maxPowerW, 0); expect(borne.minOnS, 0); expect(borne.minOffS, 0); }); test('son id EST le ThingId de la borne — donc elle revendique son Thing', () { // Ce n'est pas une commodité : c'est ce qui interdit de déclarer la même borne à la // fois comme evcharger et comme charge pilotée. Deux commandeurs sur un organe, // exactement ce que LM-201 refuse. final borne = _banc().firstWhere((x) => x.isEvCharger); expect(borne.claimedThingIds, {normalizeThingId(borne.id)}); final doublon = LoadConfigEntry({ ...borne.raw, 'id': borne.id, 'label': 'la même borne, déclarée deux fois', }); expect(validateSet([borne, doublon])?.kind, 'duplicateId'); }); test('l\'aller-retour verbatim est neutre — rien n\'est amputé', () { // `patched()` sans argument doit rendre la map d'origine à l'identique : c'est ce // qui préserve les charges utiles que l'app ne modélise pas. for (final e in _banc()) { expect(e.patched(), e.raw, reason: 'aller-retour non neutre sur ${e.id}'); } }); test('le glissement traite les bornes sans cas particulier', () { final groupes = groupByDomain(_banc()); expect(groupes.isContiguous, isTrue, reason: 'les deux bornes doivent former une plage continue dans le domaine ev'); final ev = groupes.groups.firstWhere((g) => g.domain == 'ev'); expect(ev.loads.length, 2); // Réaplatir renumérote 1..N sur TOUTE la liste, bornes comprises, et ne touche que // `priority`. Aucune branche ne regarde l'adaptateur. final charge = flattenToPayload(groupes.groups); expect(charge.length, 4); expect([for (final m in charge) m['priority']], [1, 2, 3, 4]); for (final m in charge) { final avant = _banc().firstWhere((e) => e.id == m['id']); for (final k in avant.raw.keys) { if (k == 'priority') continue; expect(m[k], avant.raw[k], reason: 'clé $k altérée sur ${m['id']}'); } } }); test('inverser une borne et une charge pilotée est un échange ordinaire', () { final groupes = groupByDomain(_banc()).groups; final inverse = [...groupes.reversed]; final charge = flattenToPayload(inverse); // Les domaines ont permuté, les rangs sont refaits, et rien d'autre n'a bougé. expect([for (final m in charge) m['priority']], [1, 2, 3, 4]); expect(charge.first['domain'], groupes.last.domain); }); }); group('supprimer une charge — le seul geste destructeur de l\'app', () { test('les AUTRES charges sont recopiées verbatim, champ par champ', () { // `SetLoadConfig` remplace l'ensemble : supprimer une charge, c'est réécrire toutes // les autres. Les reconstruire depuis le modèle typé perdrait, en silence et pour // toutes, un champ qu'une box plus récente porterait. final frais = _bancRaw(); final reste = payloadWithoutLoad(frais, 'chauffe-eau')!; expect(reste.length, frais.length - 1); expect(reste.any((m) => m['id'] == 'chauffe-eau'), isFalse); for (final avant in frais.where((m) => m['id'] != 'chauffe-eau')) { final apres = reste.firstWhere((m) => m['id'] == avant['id']); for (final k in avant.keys) { expect(apres[k], avant[k], reason: 'clé $k altérée sur ${avant['id']}'); } expect(apres.keys.length, avant.keys.length, reason: 'aucune clé ajoutée sur ${avant['id']}'); } }); test('les rangs ne sont PAS renumérotés — le trou est sans effet', () { // Renuméroter réécrirait le rang de charges que personne n'a touchées ; le tri est // sur `priority`, un trou ne change l'ordre de personne. final frais = _bancRaw(); final avant = { for (final m in frais) if (m['id'] != 'pac-terrain') m['id']: m['priority'] }; final reste = payloadWithoutLoad(frais, 'pac-terrain')!; for (final m in reste) { expect(m['priority'], avant[m['id']]); } }); test('la relecture confirme quand RIEN d\'autre n\'a bougé', () { final envoye = payloadWithoutLoad(_bancRaw(), 'chauffe-eau')!; // La box rend ce qu'on lui a donné, dans un autre ordre : l'ordre de la liste n'est // pas une différence, la comparaison est par identifiant. final relu = envoye.reversed.map((m) => {...m}).toList(); expect(diffPayloads(envoye, relu), isEmpty); }); test('un RANG changé au passage se voit — c\'est le cas du 2026-08-28', () { // Une suppression qui renumérote n'est plus un nettoyage, c'est un geste // d'arbitrage. Le 28, deux écritures à deux minutes d'écart avaient rendu // impossible de dire laquelle avait déplacé le chauffe-eau du rang 1 au rang 3. final envoye = payloadWithoutLoad(_bancRaw(), '{88160e45-7df2-48cc-9639-f0d15ae6e382}')!; final relu = [ for (final m in envoye) if (m['id'] == 'chauffe-eau') {...m, 'priority': 3} else {...m}, ]; final ecarts = diffPayloads(envoye, relu); expect(ecarts.length, 1); expect(ecarts.single, contains('priority')); expect(ecarts.single, contains('1 → 3')); }); test('une charge qui disparaît ou apparaît en plus se voit aussi', () { final envoye = payloadWithoutLoad(_bancRaw(), 'chauffe-eau')!; expect(diffPayloads(envoye, envoye.sublist(1)).single, contains('a disparu')); expect( diffPayloads(envoye.sublist(1), envoye).single, contains('est apparue')); }); test('les charges utiles imbriquées se comparent par VALEUR', () { // `relays` et `sgReady` sont des listes et des objets : `==` les comparerait par // référence, et toute relecture paraîtrait alors différente — l'alerte crierait à // chaque suppression, donc plus personne ne la lirait. final envoye = payloadWithoutLoad(_bancRaw(), 'pac-terrain')!; final relu = [ for (final m in envoye) jsonDecode(jsonEncode(m)) as Map ]; expect(diffPayloads(envoye, relu), isEmpty); }); test('une charge absente rend NULL, pas la liste inchangée', () { // « Rien à supprimer » et « supprimé » ne doivent pas se ressembler côté appelant : // une liste inchangée serait réécrite pour rien, sur la foi d'un succès. expect(payloadWithoutLoad(_bancRaw(), 'charge-qui-nexiste-pas'), isNull); }); }); group('3g-2 — le rang par défaut d\'une borne est un ARTEFACT', () { test('trois charges du banc partagent le rang 1', () { // À la création, le plugin donne « le plus petit rang existant moins un, borné à // 1 ». Le chauffe-eau occupant déjà le rang 1, l'intention « en tête » dégénère. final exAequo = tiedByPriority(_banc()); expect(exAequo.length, 3); expect(exAequo.map((e) => e.priority).toSet(), {1}); expect(exAequo.where((e) => e.isEvCharger).length, 2); }); test('l\'ordre reste reproductible — mais reproductible n\'est pas choisi', () { // Le tri est total : l'id départage, donc l'écran ne bouge pas d'une lecture à // l'autre. C'est exactement ce qui rend l'artefact crédible, et donc dangereux à // taire. final a = groupByDomain(_banc()).flatOrder.map((e) => e.id).toList(); final b = groupByDomain(_banc().reversed.toList()).flatOrder.map((e) => e.id).toList(); expect(a, b); }); test('des rangs distincts font taire la note — dès le premier vrai réglage', () { final regle = [ for (final (i, e) in _banc().indexed) LoadConfigEntry(e.patched(priority: i + 1)), ]; expect(tiedByPriority(regle), isEmpty); }); }); // ─────────────────────────────────────────────────────────────────────────── group('terrain 2026-08-28 — la V2C remplace la Terra AC', () { List banc() { final raw = File('test/fixtures/loadconfig_hems75_v2c.json').readAsStringSync(); final list = (jsonDecode(raw) as Map)['loadConfigs'] as List; return [ for (final m in list.whereType()) LoadConfigEntry(Map.from(m)), ]; } test('l\'entrée d\'une borne SURVIT à la suppression de son appareil', () { // Le plugin crée d'office une entrée par borne détectée, et ne la retire pas quand // le Thing s'en va. Deux jours après le remplacement physique, la Terra AC occupe // encore un rang dans la liste de l'installateur. final e = banc(); expect(e.length, 5); final terra = e.firstWhere((x) => x.label.contains('Terra')); expect(terra.isEvCharger, isTrue); expect(terra.enabled, isTrue, reason: 'elle n\'est pas désactivée — elle est ORPHELINE'); expect(terra.priority, 4, reason: 'et elle consomme un rang'); }); test('la note de rangs ex æquo DOIT s\'afficher sur ce banc', () { // C'est le cas réel qui justifie la note : la V2C, créée d'office, arrive au rang 1 // que le chauffe-eau occupe déjà. Personne n'a choisi l'ordre entre les deux. final exAequo = tiedByPriority(banc()); expect(exAequo, isNotEmpty); expect(exAequo.map((e) => e.label), containsAll(['chauffe-eau', 'V2C Trydan'])); expect(exAequo.length, 2, reason: 'deux charges au rang 1 — la Terra est seule au rang 4'); }); 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` apparaît en deux endroits. // // 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')); final aPlat = groupes.flatOrder.map((e) => e.domain).toList(); expect(aPlat, ['ecs', 'ev', 'heating', 'ev', 'ev']); // ⚠️ 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é 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'], 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); }); }); }