etm-powersync-app/test/rangs_distincts_test.dart
Patrick Schurig 2c193077f7 fix(config): +etm27 refuse deux rangs identiques — l'app le voit avant la box
Livré AVANT le déploiement, parce que ce refus-là est muet : le motif ne franchit pas la
frontière RPC, et le symptôme au doigt aurait été « mon glissement ne s'enregistre pas »,
sans cause visible à l'écran. Même famille que le §9 du contrat de données.

Trois points vérifiés, dans l'ordre demandé :

1. LE RÉAPLATISSAGE REND TOUJOURS DES RANGS DISTINCTS. flattenToPayload numérote 1..n sur
   l'ordre courant, donc par construction — mais « par construction » ne se vérifie pas
   tout seul : le test le refait après CHAQUE permutation de domaines et après un
   déplacement intra-domaine, en partant de la config du banc qui porte déjà deux charges
   au rang 1. Les états intermédiaires du glissement ne partent pas : seul `save()` écrit.

2. RIEN NE PART SI LE BROUILLON N'EST PAS COHÉRENT. validateSet() — le miroir client de
   LoadConfigStore::validateSet() — apprend le contrôle des rangs et rend un conflit
   `tiedPriority` qui NOMME les deux charges et le rang partagé. L'écran distingue
   désormais les trois refus (thing revendiqué deux fois, identifiant en double, rangs à
   égalité) là où il annonçait « deux charges revendiquent le même appareil » pour tout.

3. ⚠️ LE CHEMIN DE SUPPRESSION ÉTAIT LE PLUS EXPOSÉ, et rien ne l'aurait montré. Il
   réécrit les survivantes VERBATIM — c'est sa règle, et elle est bonne — donc il
   transporte les rangs tels que la box les porte. Or c'est le plugin lui-même qui a créé
   des égalités avant +etm27 (borne détectée au rang 1 d'une installation déjà réglée).
   Une suppression légitime aurait donc été refusée en bloc, sans motif. Elle refuse
   maintenant côté app, avec la raison — plutôt que de renumérer en douce : un geste de
   nettoyage qui redistribue les rangs devient un geste d'arbitrage.

Deux tests de load_priority_test construisaient leurs entrées sans `priority` : les deux
tombaient donc à égalité, et le nouveau contrôle masquait la règle qu'ils éprouvaient.
Rangs distincts posés, avec la raison en commentaire.

Les deux autres nouveautés d'+etm27 sont au TODO, non codées : `o:rankOrigin` (badge « rang
par défaut, pas un choix », avec la règle absent = rien d'affirmé) et l'entrée en queue des
bornes auto-provisionnées — qui devrait faire taire d'elle-même la note d'ordre entrelacé.

213 tests (+8), 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-28 16:06:11 +02:00

152 lines
6.3 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);
});
});
}