Le moteur a tranché l'entrelacement, et son argument va plus loin que le mien : grouper à l'affichage oui, à l'écriture non. Un regroupement appliqué à l'enregistrement est une machine qui réordonne ce qu'un humain a décidé — exactement ce que LM-1209 interdit au moteur. Et la contiguïté n'a jamais été un invariant : le waterfall trie sur le rang seul, `domain` n'est pas lu par l'arbitre. Il ne suffisait donc pas d'adoucir la note : c'est le modèle d'affichage qui la produisait. Un groupe était un SEAU par domaine, rassemblant les charges où qu'elles soient dans l'ordre — donc l'écran ne pouvait pas représenter un ordre entrelacé, et comme l'enregistrement réaplatit ce qui est affiché, TOUT enregistrement regroupait. La PAC reculait du rang 2 au rang 5 sans que personne l'ait demandé. Un groupe est maintenant une PLAGE CONTIGUË. L'affichage épouse l'ordre réel — deux plages `ev` séparées par `heating` s'affichent comme deux blocs — et réaplatir rend exactement ce qui a été lu. Test à l'appui : la PAC garde sa position, et l'ordre à plat écrit est identique à celui lu sur la box. Le regroupement reste possible, mais devient un GESTE : bouton « Regrouper par domaine », qui pose un brouillon — visible, annulable, rien ne part avant save(). Ce que l'enregistrement faisait en douce, quelqu'un le demande désormais. Et la note cesse d'alerter : l'ordre non groupé est une information, pas un défaut. L'annoncer comme une anomalie poussait à réparer un ordre que personne n'avait cassé — d'autant que depuis +etm27 une borne détectée entre en queue, ce qui produit un ordre non groupé sur une installation parfaitement saine. Trois conséquences trouvées en chemin, toutes dues au fait qu'un domaine peut désormais occuper plusieurs plages : · la clé de la liste réordonnable portait le DOMAINE → deux plages d'un même domaine partageaient une clé, et la liste cassait sur une assertion de sémantiques, loin de la cause. Elle porte l'id de la première charge de la plage. · le repli suivait le domaine → deux plages se repliaient ensemble. · reorderWithinDomain(domain) désignait la première plage venue → reorderWithinGroup(index). · assignDomain rejoint la DERNIÈRE plage du domaine, pas la première : remonter la charge dans la première la ferait sauter des rangs que personne n'a touchés. Retiré au passage : sansRankOriginAuto(). L'écho verbatim inchangé passe — mesuré côté moteur sur marque fraîche — donc la marque survit désormais aux gestes qui ne la concernent pas, et le badge ne s'éteint plus sur une borne que personne n'a classée. Noté au TODO : supprimer l'entrée d'une borne dont le Thing existe encore ne la recrée pas (défaut moteur en cours de correction). Le bouton réservé aux THING_MISSING protège déjà de ce cas — ne pas l'élargir. 226 tests, flutter analyze inchangé à 27 remarques. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
350 lines
17 KiB
Dart
350 lines
17 KiB
Dart
/// 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<LoadConfigEntry> _banc() {
|
|
final raw = File('test/fixtures/loadconfig_hems75_evcharger.json').readAsStringSync();
|
|
final list = (jsonDecode(raw) as Map<String, dynamic>)['loadConfigs'] as List;
|
|
return [
|
|
for (final m in list.whereType<Map>()) LoadConfigEntry(Map<String, dynamic>.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<Map<String, dynamic>> _bancRaw() {
|
|
final raw = File('test/fixtures/loadconfig_hems75_v2c.json').readAsStringSync();
|
|
return ((jsonDecode(raw) as Map<String, dynamic>)['loadConfigs'] as List)
|
|
.whereType<Map>()
|
|
.map((m) => Map<String, dynamic>.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<String, dynamic>
|
|
];
|
|
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<LoadConfigEntry> banc() {
|
|
final raw =
|
|
File('test/fixtures/loadconfig_hems75_v2c.json').readAsStringSync();
|
|
final list = (jsonDecode(raw) as Map<String, dynamic>)['loadConfigs'] as List;
|
|
return [
|
|
for (final m in list.whereType<Map>())
|
|
LoadConfigEntry(Map<String, dynamic>.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);
|
|
});
|
|
});
|
|
}
|