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
208 lines
8.9 KiB
Dart
208 lines
8.9 KiB
Dart
/// **`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<LoadConfigEntry> _banc([String fixture = 'loadconfig_hems75_v2c.json']) {
|
|
final raw = jsonDecode(File('test/fixtures/$fixture').readAsStringSync());
|
|
return [
|
|
for (final m in ((raw as Map<String, dynamic>)['loadConfigs'] as List))
|
|
LoadConfigEntry(Map<String, dynamic>.from(m as Map)),
|
|
];
|
|
}
|
|
|
|
/// Les rangs d'une charge utile prête à partir.
|
|
List<int> _rangs(List<Map<String, dynamic>> 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<String, dynamic>.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<String, dynamic>.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);
|
|
});
|
|
|
|
});
|
|
}
|