Trouvé en relisant .75 après le déploiement d'+etm27, pas en écrivant le badge. `rankOrigin: "auto"` écrit par un client est refusé : EnergyErrorInvalidParameter, ensemble entier rejeté (vérifié sur machine). Or les deux chemins d'écriture de l'app recopient les charges VERBATIM — c'est leur règle, et elle est bonne. Recopier verbatim une entrée marquée "auto", c'est écrire "auto". Conséquence, qu'aucun test ne montrait et que le banc ne peut pas produire aujourd'hui (aucune borne auto-provisionnée depuis +etm27) : dès qu'une installation cliente portera une borne détectée par le moteur, un SIMPLE RENOMMAGE aurait été refusé en bloc — et le motif ne franchit pas la frontière RPC. Même famille que le refus sur rangs doublés d'hier, même symptôme muet. Le nettoyage est posé au point de sortie UNIQUE (NymeaService.setLoadConfig) plutôt que sur chaque chemin : un chemin ajouté demain hériterait sinon du refus sans que rien ne l'annonce. `"user"` n'est pas touché — il est permis — et une clé absente le reste : on n'en fabrique pas. ⚠️ Ce que ça coûte est dit dans le code et remonté au brief : la marque du moteur est EFFACÉE sur les charges concernées, y compris celles que le geste ne visait pas. Le badge « rang posé par la box » disparaîtra donc d'une borne que personne n'a classée, au premier renommage d'une autre charge. Question posée au moteur : un écho inchangé de "auto" peut-il être toléré ? Trois options écartées, avec leurs raisons. Et le relevé du jour précise la lecture : depuis +etm27 la clé sort TOUJOURS, à `""` quand rien n'est déclaré — même convention que `domain` et `meterThingId`. Les quatre charges du banc portent `rankOrigin: ""`. La traiter comme une valeur afficherait un badge sur toute l'installation ; le getter la lit comme un silence, et quatre tests le figent. 222 tests (+4), flutter analyze inchangé à 27 remarques. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
209 lines
8.8 KiB
Dart
209 lines
8.8 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('⚠️ ce qui PART ne porte jamais `"auto"` — la box le refuserait en bloc',
|
|
() {
|
|
// Les chemins d'écriture recopient verbatim : sans ce nettoyage, un simple renommage
|
|
// renverrait la marque que le moteur a posée, et l'ensemble serait rejeté
|
|
// (EnergyErrorInvalidParameter) dès qu'une borne auto-provisionnée existe.
|
|
final envoye = sansRankOriginAuto([
|
|
{'id': 'a', 'priority': 1, 'rankOrigin': 'auto'},
|
|
{'id': 'b', 'priority': 2, 'rankOrigin': 'user'},
|
|
{'id': 'c', 'priority': 3, 'rankOrigin': ''},
|
|
{'id': 'd', 'priority': 4},
|
|
]);
|
|
|
|
expect(envoye[0]['rankOrigin'], '', reason: 'auto → effacé');
|
|
expect(envoye[1]['rankOrigin'], 'user', reason: 'user est permis, on n\'y touche pas');
|
|
expect(envoye[2]['rankOrigin'], '');
|
|
expect(envoye[3].containsKey('rankOrigin'), isFalse,
|
|
reason: 'une clé absente le reste — on n\'en fabrique pas');
|
|
});
|
|
|
|
test('le nettoyage ne touche à RIEN d\'autre', () {
|
|
final avant = {
|
|
'id': 'a',
|
|
'label': 'A',
|
|
'priority': 1,
|
|
'rankOrigin': 'auto',
|
|
'relays': [
|
|
{'thingId': '{x}', 'powerW': 500}
|
|
],
|
|
};
|
|
final apres = sansRankOriginAuto([avant]).single;
|
|
for (final k in avant.keys) {
|
|
if (k == 'rankOrigin') continue;
|
|
expect(apres[k], avant[k], reason: 'clé $k altérée');
|
|
}
|
|
expect(apres.keys.length, avant.keys.length);
|
|
});
|
|
});
|
|
}
|