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