etm-powersync-app/test/rangs_distincts_test.dart
Patrick Schurig 99a24a5f3e feat(config): « Ce rang me convient » — adopter un rang sans le changer
`"user"` n'est pas « j'ai vu le badge ». C'est « ce rang me va, je l'assume » : un jugement
porté sur l'ordre de service, et la seule voie pour éteindre le badge SANS changer
l'arbitrage. Les trois conséquences sont dans le code, parce qu'elles sont faciles à perdre.

1. LE GESTE EST EXPLICITE. Un bouton sur le badge, appelé de là et de nulle part ailleurs.
   Jamais une écriture au passage, jamais l'effet de bord d'un autre enregistrement :
   écrire "user" parce que l'utilisateur a touché à autre chose affirmerait un jugement
   qu'il n'a pas porté — le reproche exact qu'on fait à un "auto" fabriqué côté client.

2. IL NE S'ANNULE PAS TOUT SEUL. Une fois posé, seul le moteur le lève, quand un rang
   différent arrive pour cet id. L'app ne le retire jamais.

3. IL NE REMPLACE PAS LE DÉPLACEMENT, et le libellé le dit : « Adopter ce rang ne le change
   pas. Pour en changer, glissez la charge. » Sans cette phrase, un installateur valide un
   ordre qu'il voulait changer — et n'y reviendra pas, puisque le badge s'est éteint.

L'écriture emprunte le chemin de la suppression, pour la même raison : SetLoadConfig
remplace tout l'ensemble. Lire (frais) → patcher le SEUL rankOrigin de la SEULE charge visée
→ réécrire verbatim → relire et comparer clé par clé. Le rang ne bouge pas : « ce rang me
va » ne peut pas porter sur un rang qu'on vient de changer. Garde-fou des rangs à égalité
inclus, et refus si un brouillon est en cours.

Vérifié au passage, comme demandé : sansRankOriginAuto() laisse passer "user" — le confondre
avec "auto" aurait rendu le bouton silencieusement inopérant. Test dédié, en plus de celui
qui existait.

Quatre tests de plus : le bouton est là sur "auto" avec sa phrase, absent sur "user" et sur
champ vide, et la charge utile d'une adoption ne diffère que par rankOrigin.

⚠️ Non vérifiable sur `.75` : les deux bornes y sont antérieures à `+etm27`, donc aucun
"auto" n'y est observable. À reprendre au premier appairage réel d'une borne.

226 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
2026-08-29 10:28:21 +02:00

236 lines
10 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('`"user"` PASSE le point de sortie — sinon le geste ne partirait jamais', () {
// Le filtre vise `"auto"`, que l'app n'a pas le droit d'écrire. `"user"` est
// précisément ce qu'elle a le droit d'écrire : le confondre avec `"auto"` rendrait
// le bouton « ce rang me convient » silencieusement inopérant.
final envoye = sansRankOriginAuto([
{'id': 'a', 'priority': 1, 'rankOrigin': 'user'},
]);
expect(envoye.single['rankOrigin'], 'user');
});
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);
});
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);
});
});
}